// oidc_auth.go — Phase 0.6 BFF OIDC handler 實作。 // // 對齊文件: // - oidc-tdd.md §3.1(首次登入流程) // - oidc-tdd.md §3.3(登出) // - oidc-tdd.md §4.5(handler 程式碼範例) // - oidc-tdd.md §6(PKCE) // - oidc-tdd.md §7(id_token 驗證) // - ADR-010(BFF 模式) // // 與既有 auth.go(Static 路徑)並存,由 NewRouter 依 Deps.OIDCEnabled() 決定是否註冊。 // // 設計選擇: // - 把 OIDC pending state(state / nonce / code_verifier / return_to)合在 // usersession.Session 同一個 cookie 裡。雛形階段 pending 與已登入 session // 共用同一個 store;callback 完成後 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/O(IdP 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 規劃但實際整合在 registerAuthRoutes(auth.go)裡: // /api/auth/me 和 /api/auth/logout 在 OIDC 模式下需要不同的 handler, // 由 registerAuthRoutes 依 deps.OIDCEnabled() 動態選擇。 // oidcLoginHandler 實作 GET /api/auth/login(OIDC 模式)。 // // 流程(對齊 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/callback(OIDC 模式)。 // // 對齊 oidc-tdd.md §3.1 步驟 9-12 / §4.5: // 1. 處理 IdP error response(user 取消、IdP 錯誤) // 2. 從 cookie 拿 pending session // 3. 比對 state(CSRF 防護) // 4. ExchangeCode(PKCE) // 5. VerifyIDToken(驗簽 + nonce) // 6. RotateSessionID(Fix-A1:session fixation 防護,OWASP ASVS V3.2.1) // 7. 把 claims 寫入新 session(UserID / Email / Name),清 OIDC pending state,清 return_to // 8. UpdateSession(LastSeenAt 自動刷新) // 9. 302 回 frontend 的 PostLoginURL + return_to // // 失敗一律回 JSON 錯誤(4xx / 5xx);callback 是「夾在中間」的 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.1):user 拒絕授權、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 } // 驗 state(CSRF 防護)— 用常數時間比對避免 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 用)。 // 注意:絕對不可進入 log(oidc-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 清理併入同一次 UpdateSession(Major-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 URL:PostLoginURL + 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/logout(OIDC 模式)。 // // 雛形不做 RP-initiated logout(不通知 IdP)— 只清本地 session + cookie。 // Idempotent:cookie 不存在或 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/me(OIDC 模式)。 // // 主要從 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 含 Name;UserContext 沒有,所以從 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 }