jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。

OIDC / pairing FK 修復(接 DB 上線必要):
- 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback
  驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID)
- pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token;
  自建 device 空 serial 寫 NULL(避免撞 partial unique)
- device.SaveTx / session.CreateTx 新增 tx-aware 版本

B4 model metadata:
- 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model:
  converter_client → flow → adapter → model.Model → PG → ModelResponse DTO
- input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性
- 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md

nginx healthz(部署層):
- 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB
- 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因)

storage error 統一映射(不洩漏 storage 後端細節)。

測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、
internal/db(redis)、relay/session 弱處,含 testcontainers integration。
DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:36:35 +08:00

432 lines
17 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.

// oidc_auth.go — Phase 0.6 BFF OIDC handler 實作。
//
// 對齊文件:
// - oidc-tdd.md §3.1(首次登入流程)
// - oidc-tdd.md §3.3(登出)
// - oidc-tdd.md §4.5handler 程式碼範例)
// - oidc-tdd.md §6PKCE
// - oidc-tdd.md §7id_token 驗證)
// - ADR-010BFF 模式)
//
// 與既有 auth.goStatic 路徑)並存,由 NewRouter 依 Deps.OIDCEnabled() 決定是否註冊。
//
// 設計選擇:
// - 把 OIDC pending statestate / nonce / code_verifier / return_to合在
// usersession.Session 同一個 cookie 裡。雛形階段 pending 與已登入 session
// 共用同一個 storecallback 完成後 pending 欄位清空、寫入 UserID/Email/Name。
// 簡化實作、減少 cookie 數量symmetrically pending 持續時間短(≤ 10 分鐘)。
// - 不另外發 visiona_pending_sid cookie與 oidc-tdd.md §4.5 範例不同 — TDD 是文件示意,
// 雛形採取「合一 session」策略這個權衡記錄於 OB4 任務說明)。
package api
import (
"context"
"crypto/subtle"
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"visiona-backend/internal/oidc"
"visiona-backend/internal/user"
)
// oidcCallbackTimeout 限制 token exchange + id_token verify 的總時間。
// 這兩步都有網路 I/OIdP token endpoint、JWKS 抓取30s 足以涵蓋 IdP 緩慢回應,
// 又不會讓 caller 端等到 default HTTP server timeout。
const oidcCallbackTimeout = 30 * time.Second
// MeResponseOIDC 是 OIDC 模式下 GET /api/auth/me 的 data payload。
//
// 故意與 Legacy MeResponse 區分OIDC 沒有 Roles 概念(雛形),但有 Name。
type MeResponseOIDC struct {
UserID string `json:"user_id"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
}
// LogoutResponse 是 POST /api/auth/logout 的 data payload。
type LogoutResponse struct {
Success bool `json:"success"`
}
// registerOIDCPublicRoutes 註冊「不需登入即可訪問」的 OIDC endpoints。
//
// 這兩個 endpoint 必須在 AuthMiddleware 之前註冊,否則 user 沒登入根本進不來。
//
// 路徑刻意與 Legacy /api/auth/* 保持一致 — 因為 OIDC 啟用時 Legacy 的 /api/auth/login
// (在 apiGroup 下)會變成「已登入才能呼叫的端點」、且仍會回 501 因為 deps.AuthProvider 通常為 nil。
// 實際生效的是這裡註冊的 OIDC 版本。
func registerOIDCPublicRoutes(r *gin.Engine, deps Deps) {
r.GET("/api/auth/login", oidcLoginHandler(deps))
r.GET("/api/auth/callback", oidcCallbackHandler(deps))
}
// registerOIDCAuthedRoutes 是被 OB4 規劃但實際整合在 registerAuthRoutesauth.go
// /api/auth/me 和 /api/auth/logout 在 OIDC 模式下需要不同的 handler
// 由 registerAuthRoutes 依 deps.OIDCEnabled() 動態選擇。
// oidcLoginHandler 實作 GET /api/auth/loginOIDC 模式)。
//
// 流程(對齊 oidc-tdd.md §3.1 步驟 3
// 1. 解析 return_to query param白名單檢查避免 open redirect
// 2. 產 PKCE code_verifier / state / nonce皆 32 byte 隨機)
// 3. 透過 SessionManager.StartSession 建立 pending session含 cookie
// 4. 把 OIDC state 寫入 session 並 Update讓 callback 能讀到)
// 5. 算出 IdP authorize URL含 state / nonce / code_challenge
// 6. 302 redirect user 到 IdP
//
// 任何步驟失敗 → 500沒 session 可清 → 不需 fallback handling
// 不直接回 JSON 錯誤redirect 才是這個 endpoint 的合約。失敗時用 WriteError 較直觀。
func oidcLoginHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
log := logOrDefault(deps.Logger)
returnTo := sanitizeReturnTo(c.Query("return_to"))
verifier, err := oidc.GenerateCodeVerifier()
if err != nil {
log.Error("oidc.login: generate code verifier failed", "error", err, "request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to start login flow", nil)
return
}
state, err := oidc.GenerateState()
if err != nil {
log.Error("oidc.login: generate state failed", "error", err, "request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to start login flow", nil)
return
}
nonce, err := oidc.GenerateNonce()
if err != nil {
log.Error("oidc.login: generate nonce failed", "error", err, "request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to start login flow", nil)
return
}
// 開新 session含 cookie。先 Start 再 Update — Update 會把 OIDC state 寫進 store。
sess, err := deps.SessionManager.StartSession(c.Request.Context(), c.Writer)
if err != nil {
log.Error("oidc.login: start session failed", "error", err, "request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to start session", nil)
return
}
sess.OIDCState = state
sess.OIDCNonce = nonce
sess.OIDCCodeVerifier = verifier
if returnTo != "" {
if sess.Extra == nil {
sess.Extra = make(map[string]any, 1)
}
sess.Extra["return_to"] = returnTo
}
if err := deps.SessionManager.UpdateSession(c.Request.Context(), sess); err != nil {
// 清 cookie 避免 user 拿到沒對應 store record 的 zombie cookie
_ = deps.SessionManager.EndSession(c.Request.Context(), c.Writer, c.Request)
log.Error("oidc.login: update pending session failed", "error", err, "request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to persist pending session", nil)
return
}
challenge := oidc.CodeChallenge(verifier)
authURL := deps.OIDCProvider.AuthorizationURL(state, nonce, challenge)
log.Info("oidc.login: redirecting to IdP",
"request_id", RequestIDFrom(c),
"action", "oidc.login.redirect",
"return_to", returnTo,
)
c.Redirect(http.StatusFound, authURL)
}
}
// oidcCallbackHandler 實作 GET /api/auth/callbackOIDC 模式)。
//
// 對齊 oidc-tdd.md §3.1 步驟 9-12 / §4.5
// 1. 處理 IdP error responseuser 取消、IdP 錯誤)
// 2. 從 cookie 拿 pending session
// 3. 比對 stateCSRF 防護)
// 4. ExchangeCodePKCE
// 5. VerifyIDToken驗簽 + nonce
// 6. RotateSessionIDFix-A1session fixation 防護OWASP ASVS V3.2.1
// 7. 把 claims 寫入新 sessionUserID / Email / Name清 OIDC pending state清 return_to
// 8. UpdateSessionLastSeenAt 自動刷新)
// 9. 302 回 frontend 的 PostLoginURL + return_to
//
// 失敗一律回 JSON 錯誤4xx / 5xxcallback 是「夾在中間」的 endpoint
// 直接 redirect user 到 frontend 的 error 頁也是選項,但雛形先回 JSON 便於測試。
func oidcCallbackHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
log := logOrDefault(deps.Logger)
ctx, cancel := context.WithTimeout(c.Request.Context(), oidcCallbackTimeout)
defer cancel()
// IdP 錯誤回應OAuth 2.0 §4.1.2.1user 拒絕授權、IdP 內部錯誤等
if errCode := c.Query("error"); errCode != "" {
errDesc := c.Query("error_description")
log.Warn("oidc.callback: IdP returned error",
"request_id", RequestIDFrom(c),
"error_code", errCode,
"error_description", errDesc,
)
// 清掉 pending session即使存在確保 cookie 不會殘留
_ = deps.SessionManager.EndSession(ctx, c.Writer, c.Request)
WriteError(c, http.StatusBadRequest, ErrCodeUnauthorized,
"identity provider returned error: "+errCode, nil)
return
}
code := c.Query("code")
state := c.Query("state")
if code == "" || state == "" {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
"missing code or state query parameter", nil)
return
}
// 從 cookie 取 pending session
sess, err := deps.SessionManager.GetSession(ctx, c.Request)
if err != nil {
log.Warn("oidc.callback: pending session not found",
"request_id", RequestIDFrom(c), "error", err)
WriteError(c, http.StatusBadRequest, ErrCodeUnauthorized, "no pending session", nil)
return
}
// 驗 stateCSRF 防護)— 用常數時間比對避免 timing attack
if subtle.ConstantTimeCompare([]byte(sess.OIDCState), []byte(state)) != 1 {
log.Warn("oidc.callback: state mismatch",
"request_id", RequestIDFrom(c))
// state 不對 → 視為攻擊嘗試或過期 session刪掉重來
_ = deps.SessionManager.EndSession(ctx, c.Writer, c.Request)
WriteError(c, http.StatusBadRequest, ErrCodeUnauthorized, "state mismatch", nil)
return
}
// 換 token
tok, err := deps.OIDCProvider.ExchangeCode(ctx, code, sess.OIDCCodeVerifier)
if err != nil {
log.Warn("oidc.callback: token exchange failed",
"request_id", RequestIDFrom(c), "error", err)
status := http.StatusBadGateway
if errors.Is(err, oidc.ErrInvalidGrant) {
status = http.StatusBadRequest
}
WriteError(c, status, ErrCodeUnauthorized, "token exchange failed", nil)
return
}
// 驗 id_token含 nonce 比對)
claims, err := deps.OIDCProvider.VerifyIDToken(ctx, tok.IDToken, sess.OIDCNonce)
if err != nil {
log.Warn("oidc.callback: id_token verification failed",
"request_id", RequestIDFrom(c), "error", err)
WriteError(c, http.StatusUnauthorized, ErrCodeUnauthorized, "id_token verification failed", nil)
return
}
// Provision users 列DB-on FK 收尾,問題 #1
//
// D1-BOIDC sub 直接當 users.idMember Center sub 為 UUID。upsert 後 users 表才有
// 這筆使用者,後續帶 owner_user_id FK 的寫入model 上傳 / 配對 / pairing token才不會
// FK violation。in-memory 模式也呼叫(行為對齊),但 in-memory 不檢查 FK僅維持對稱。
//
// 在「驗 id_token 成功後、寫 session 之前」provisionfail-closed —— provision 失敗就不發
// session否則使用者拿到能登入的 cookie 但 DB 沒對應 user下一個寫入照樣爆且更難診斷
// UserStore 為 nil最小骨架 / 純 OIDC unit test→ 略過 upsert。
if deps.UserStore != nil {
if upErr := deps.UserStore.Upsert(ctx, &user.User{
ID: claims.Subject, // = users.idD1-B
Email: claims.Email,
Name: claims.Name,
}); upErr != nil {
// 不洩漏 raw error 給 userlog 留診斷(不含 token / secret
log.Error("oidc.callback: provision user failed",
"request_id", RequestIDFrom(c),
"user_id", claims.Subject,
"error", upErr)
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"failed to provision user", nil)
return
}
}
// Session fixation 防護OWASP ASVS V3.2.1)— Fix-A1 / Major-1。
//
// 在「驗 id_token 成功後、寫使用者 info 進 session 之前」rotate session ID。
// 這樣攻擊者預先誘騙受害者使用的 pending cookie 在這一刻失效,
// 即使攻擊者持有舊 cookie 也無法接續成「已登入」狀態。
//
// rotate 失敗 → 不能讓登入完成fail-closed。清掉舊 cookie回 500。
newSess, err := deps.SessionManager.RotateSessionID(ctx, c.Writer, c.Request)
if err != nil {
log.Error("oidc.callback: session rotation failed",
"request_id", RequestIDFrom(c), "error", err)
// 把舊 session 也清掉,避免 stale pending session 留著。
_ = deps.SessionManager.EndSession(ctx, c.Writer, c.Request)
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to rotate session", nil)
return
}
// 後續所有 session 操作都用 newSess舊的已不可達
sess = newSess
// 寫 session清 pending state填 user info
sess.UserID = claims.Subject
sess.Email = claims.Email
sess.Name = claims.Name
// 雛形 access_token / id_token raw 仍保留在 session未來 RP-initiated logout 用)。
// 注意:絕對不可進入 logoidc-tdd.md §14.5)。
sess.AccessToken = tok.AccessToken
sess.IDTokenRaw = tok.IDToken
// 清掉 OIDC pending state
sess.OIDCState = ""
sess.OIDCNonce = ""
sess.OIDCCodeVerifier = ""
// 取 return_to在 login handler 寫入 sess.Extra經 rotation 後仍保留)
returnTo := "/"
if v, ok := sess.Extra["return_to"]; ok {
if s, ok := v.(string); ok && s != "" {
returnTo = s
}
}
// 把 return_to 清理併入同一次 UpdateSessionMajor-4 修復:避免吞錯誤的二次 Update
// 之前是先 UpdateSession 寫 user info、再 UpdateSession 清 return_to 並 _ = err 吞錯誤;
// 現在合一:清 Extra → 一次 UpdateSession 把 user info + return_to 清理同時 commit。
if sess.Extra != nil {
delete(sess.Extra, "return_to")
}
if err := deps.SessionManager.UpdateSession(ctx, sess); err != nil {
log.Error("oidc.callback: update session failed",
"request_id", RequestIDFrom(c), "error", err)
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "failed to persist session", nil)
return
}
// 算 redirect URLPostLoginURL + return_to。
//
// 用 url.Parse + ResolveReference 而非字串拼接:
// - 字串拼接會在 PostLoginURL 帶 trailing slash + returnTo 帶 leading slash
// 時產生 "//",被瀏覽器當 protocol-relative URL 跳到外部站。
// - ResolveReference 正確處理 trailing slash、保留 query / fragment、
// 且若 returnTo 不慎含 scheme/host理論上 sanitizeReturnTo 已擋)會
// 被當成絕對 URL 取代 base — 我們再用 SameHost 檢查防禦性兜底。
//
// returnTo 已經 sanitizeReturnTo"/" 開頭、無 "//"、無 "://"),這裡是雙重防護。
redirectURL := returnTo
if deps.OIDCPostLoginURL != "" {
base, baseErr := url.Parse(deps.OIDCPostLoginURL)
ref, refErr := url.Parse(returnTo)
if baseErr != nil || refErr != nil || base.Host == "" {
// PostLoginURL / returnTo 不是合法 URL — 退回 same-origin。
log.Warn("oidc.callback: parse redirect base/ref failed, falling back to same-origin",
"request_id", RequestIDFrom(c), "base_err", baseErr, "ref_err", refErr)
redirectURL = returnTo
} else {
resolved := base.ResolveReference(ref)
// 防禦性檢查resolve 後 host 必須仍等於 base.Host避免 returnTo 偷渡 host
if resolved.Host != base.Host || resolved.Scheme != base.Scheme {
log.Warn("oidc.callback: resolved redirect host/scheme mismatch, falling back",
"request_id", RequestIDFrom(c),
"base_host", base.Host, "resolved_host", resolved.Host)
redirectURL = returnTo
} else {
redirectURL = resolved.String()
}
}
}
log.Info("oidc.callback: login success",
"request_id", RequestIDFrom(c),
"action", "oidc.callback.success",
"user_id", claims.Subject,
)
c.Redirect(http.StatusFound, redirectURL)
}
}
// oidcLogoutHandler 實作 POST /api/auth/logoutOIDC 模式)。
//
// 雛形不做 RP-initiated logout不通知 IdP— 只清本地 session + cookie。
// Idempotentcookie 不存在或 session 已清也回 200。
//
// 對齊 oidc-tdd.md §3.3。
func oidcLogoutHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
log := logOrDefault(deps.Logger)
var userID string
if uc, ok := UserContextFrom(c); ok {
userID = uc.UserID
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
if err := deps.SessionManager.EndSession(ctx, c.Writer, c.Request); err != nil {
// EndSession 內部已清 cookie只 log 不 fail保持 idempotent
log.Warn("oidc.logout: end session reported error",
"request_id", RequestIDFrom(c), "error", err)
}
log.Info("oidc.logout",
"request_id", RequestIDFrom(c),
"action", "oidc.logout",
"user_id", userID,
)
WriteSuccess(c, http.StatusOK, LogoutResponse{Success: true})
}
}
// oidcMeHandler 實作 GET /api/auth/meOIDC 模式)。
//
// 主要從 AuthMiddleware 注入的 UserContext / Session 取資料 — 不再呼叫 store。
// 對齊 oidc-tdd.md §4.5 Me 範例。
func oidcMeHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
uc, ok := UserContextFrom(c)
if !ok || uc == nil {
WriteError(c, http.StatusUnauthorized, ErrCodeUnauthorized, "not authenticated", nil)
return
}
// Session 含 NameUserContext 沒有,所以從 session 拿
var name string
if sess, ok := UserSessionFrom(c); ok && sess != nil {
name = sess.Name
}
WriteSuccess(c, http.StatusOK, MeResponseOIDC{
UserID: uc.UserID,
Email: uc.Email,
Name: name,
})
}
}
// sanitizeReturnTo 防止 open redirect 攻擊。
//
// 規則:
// - 必須以 "/" 開頭(同 origin path
// - 不能以 "//" 開頭protocol-relative URL會跳到攻擊者站
// - 不能含 "://" 或 "\"(避免各種 URL parsing trick
//
// 不合規回空字串caller 視為「沒指定」,會走預設 "/")。
func sanitizeReturnTo(raw string) string {
if raw == "" {
return ""
}
if !strings.HasPrefix(raw, "/") {
return ""
}
if strings.HasPrefix(raw, "//") {
return ""
}
if strings.Contains(raw, "://") || strings.Contains(raw, "\\") {
return ""
}
return raw
}