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

406 lines
16 KiB
Go
Raw Permalink 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"
)
// 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
}
// 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
}