接 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>
540 lines
23 KiB
Go
540 lines
23 KiB
Go
// 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"
|
||
"visiona-backend/internal/user"
|
||
)
|
||
|
||
// oidcCallbackTimeout 限制 token exchange + id_token verify 的總時間。
|
||
// 這兩步都有網路 I/O(IdP token endpoint、JWKS 抓取);30s 足以涵蓋 IdP 緩慢回應,
|
||
// 又不會讓 caller 端等到 default HTTP server timeout。
|
||
const oidcCallbackTimeout = 30 * time.Second
|
||
|
||
// fallbackEmailDomain 是 OIDC email claim 缺漏時,用 sub 組 placeholder email 的網域。
|
||
//
|
||
// 背景:Member Center(MC)的 id_token 目前不發 email claim(ASP.NET Identity 預設只發
|
||
// sub/name)。A7 之後 OIDC callback 會把 claims provision 進 users 表,而 users.email 是
|
||
// NOT NULL(防呆檢查保留在 user store 層)。若 email 為空 → Upsert 失敗 → 登入 500。
|
||
//
|
||
// 修法:email claim 缺時,用 "<sub>@<fallbackEmailDomain>" 當 placeholder:
|
||
// - 用 sub 保證唯一(不撞 users 表 lower(email) unique index)
|
||
// - 用明顯假的 .local TLD(RFC 6762 保留、不可能是真 email)標記「這不是真 email」
|
||
//
|
||
// MC 端根治(讓 id_token 發真 email)交接給 MC 團隊,見
|
||
// docs/autoflow/04-architecture/mc-email-claim-handoff.md。MC 修好後,同一個 sub 再次登入,
|
||
// Upsert 的 ON CONFLICT(id) DO UPDATE 會把 placeholder 覆寫成真 email(無需手動清資料)。
|
||
const fallbackEmailDomain = "noemail.visiona.local"
|
||
|
||
// isFallbackEmail 回報 email 是否為本系統產生的 fallback placeholder(而非真 email)。
|
||
//
|
||
// 供 log / 後續資料盤點判斷「哪些 user 還是 fallback、待 MC 修好後自動覆寫」。
|
||
func isFallbackEmail(email string) bool {
|
||
return strings.HasSuffix(email, "@"+fallbackEmailDomain)
|
||
}
|
||
|
||
// resolveProvisionEmail 決定 provision 進 users 表時要寫入的 email。
|
||
//
|
||
// - claimEmail 非空(MC 有發 / 未來修好)→ 直接用真 email、不套 fallback。
|
||
// - claimEmail 為空(MC 現況)→ 用 "<sub>@noemail.visiona.local" placeholder。
|
||
//
|
||
// 回傳 (email, isFallback);isFallback=true 時 caller 應 log 標記。
|
||
// sub 理論上不會為空(VerifyIDToken 已驗 sub),但防禦性地仍會組出合法 email 字串。
|
||
func resolveProvisionEmail(sub, claimEmail string) (email string, isFallback bool) {
|
||
if claimEmail != "" {
|
||
return claimEmail, false
|
||
}
|
||
return sub + "@" + fallbackEmailDomain, true
|
||
}
|
||
|
||
// 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。
|
||
//
|
||
// IDPLogout 為**選填**:只有當 deps.OIDCLogoutURL 有設定時才回傳,告訴前端
|
||
// 「除了清掉 visionA session,還要連帶登出 IdP(Member Center)session」。
|
||
// 未設定時欄位 omitempty 不出現、維持「只清本地」的向下相容行為。
|
||
type LogoutResponse struct {
|
||
Success bool `json:"success"`
|
||
|
||
// IDPLogout 帶 IdP(MC)登出資訊,供前端觸發 MC 登出(讓使用者能換帳號)。
|
||
// nil 時代表未啟用 MC 連動登出。
|
||
IDPLogout *IDPLogoutInfo `json:"idp_logout,omitempty"`
|
||
}
|
||
|
||
// IDPLogoutInfo 描述「如何觸發 IdP(Member Center)登出」。
|
||
//
|
||
// 為什麼需要 Method:MC 不支援標準 OIDC RP-initiated logout,唯一瀏覽器可觸發的登出是
|
||
// MemberCenter.**Web**(:7880)的 /account/logout。實測 GET 即回 302(清 session + redirect),
|
||
// 所以前端可直接 window.location = url 觸發,不必搞 form POST。故 Method="GET"。
|
||
//
|
||
// 注意(host):必須指向 MC **Web**(:7880),不是 MC **Api**(:7850)。:7850 沒有 logout 頁
|
||
// (AccountController.Logout 在 Web 端),打 :7850 會 404。MC Web/Api 共享 DataProtection
|
||
// (SetApplicationName("MemberCenter")),清 :7880 session 後 :7850 authorize 也視為未登入,
|
||
// 下次登入會問帳密、能換帳號。
|
||
//
|
||
// 流程交接(前端):拿到 IDPLogout 後 →
|
||
// 1. 先(或同時)打 visionA logout(本 response 已清本地 session)
|
||
// 2. window.location = URL(GET)觸發 MC :7880 登出
|
||
// 3. ⚠️ stage MC 跑 master 舊版(2026-04-30 image),logout **不支援 returnUrl**,
|
||
// 清完 session 會 RedirectToAction("Index","Home") 停在 MC 首頁、不會自動回 visionA。
|
||
// 前端需自行把使用者帶回 visionA 登入(GET /api/auth/login)。
|
||
//
|
||
// 注意(跨網域):MC /account/logout 在另一個 origin。GET 導向不受 antiforgery/CORS 限制
|
||
// (瀏覽器直接 navigate),比舊的跨站 form POST 單純。
|
||
type IDPLogoutInfo struct {
|
||
// URL 是 IdP(MC Web,:7880)的瀏覽器登出入口。例:
|
||
// https://stage-9527.innovedus.com:7880/account/logout
|
||
URL string `json:"url"`
|
||
|
||
// Method 是觸發登出要用的 HTTP method。MC Web(:7880) GET 即可觸發、固定為 "GET"。
|
||
Method string `json:"method"`
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// Provision users 列(DB-on FK 收尾,問題 #1)。
|
||
//
|
||
// D1-B:OIDC sub 直接當 users.id(Member Center sub 為 UUID)。upsert 後 users 表才有
|
||
// 這筆使用者,後續帶 owner_user_id FK 的寫入(model 上傳 / 配對 / pairing token)才不會
|
||
// FK violation。in-memory 模式也呼叫(行為對齊),但 in-memory 不檢查 FK,僅維持對稱。
|
||
//
|
||
// 在「驗 id_token 成功後、寫 session 之前」provision:fail-closed —— provision 失敗就不發
|
||
// session(否則使用者拿到能登入的 cookie 但 DB 沒對應 user,下一個寫入照樣爆,且更難診斷)。
|
||
// UserStore 為 nil(最小骨架 / 純 OIDC unit test)→ 略過 upsert。
|
||
//
|
||
// email fallback(放寬 email 必填):MC 現況不發 email claim,claims.Email 為空。
|
||
// users.email NOT NULL 的防呆檢查保留在 user store 層;這裡在 provision 前先把空 email
|
||
// 補成 "<sub>@noemail.visiona.local" placeholder,讓登入能成功。MC 修好後同 sub 再登入,
|
||
// Upsert ON CONFLICT(id) DO UPDATE 會自動把 placeholder 覆寫成真 email。
|
||
provisionEmail, emailIsFallback := resolveProvisionEmail(claims.Subject, claims.Email)
|
||
if emailIsFallback {
|
||
// log 標記哪些 user 是 fallback,方便盤點「待 MC 修好後覆寫」的數量。
|
||
// 不 log email 內容(雖是 placeholder 無敏感性,仍保守只記 sub)。
|
||
log.Warn("oidc.callback: email claim missing, using fallback placeholder",
|
||
"request_id", RequestIDFrom(c),
|
||
"action", "oidc.callback.email_fallback",
|
||
"user_id", claims.Subject,
|
||
)
|
||
}
|
||
if deps.UserStore != nil {
|
||
if upErr := deps.UserStore.Upsert(ctx, &user.User{
|
||
ID: claims.Subject, // = users.id(D1-B)
|
||
Email: provisionEmail,
|
||
Name: claims.Name,
|
||
}); upErr != nil {
|
||
// 不洩漏 raw error 給 user;log 留診斷(不含 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 用)。
|
||
// 注意:絕對不可進入 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 模式)。
|
||
//
|
||
// 行為:一律清掉 visionA 自己的 session + cookie(原有行為,不變)。
|
||
// Idempotent:cookie 不存在或 session 已清也回 200。
|
||
//
|
||
// MC 連動登出(2026-06 新增):MC **不支援標準 OIDC RP-initiated logout**
|
||
// (discovery 列的 end_session_endpoint 是 POST+JSON API、非標準 GET 流程,且 client
|
||
// 未註冊 post_logout_redirect_uri),所以後端無法乾淨地 302 導向 MC end_session。
|
||
// 改採權宜方案:若 deps.OIDCLogoutURL 有設定,response 多回 idp_logout 欄位
|
||
// (MC Web :7880 /account/logout 的 GET 入口),由前端 navigate 觸發 MC 登出,
|
||
// 讓使用者登出後能換帳號。未設定時不回該欄位、維持「只清本地」舊行為(向下相容)。
|
||
//
|
||
// TODO:MC 補上標準 RP-initiated logout(GET end_session_endpoint + id_token_hint +
|
||
// post_logout_redirect_uri)後,改回後端 302 導向標準流程、移除這個 idp_logout 權宜欄位。
|
||
//
|
||
// 對齊 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)
|
||
}
|
||
|
||
resp := LogoutResponse{Success: true}
|
||
// 只有設定了 MC logout URL 才回 idp_logout(可關設計、向下相容)。
|
||
if deps.OIDCLogoutURL != "" {
|
||
resp.IDPLogout = &IDPLogoutInfo{
|
||
URL: deps.OIDCLogoutURL,
|
||
Method: http.MethodGet, // MC Web(:7880) /account/logout GET 即 302 觸發登出(實測)
|
||
}
|
||
}
|
||
|
||
log.Info("oidc.logout",
|
||
"request_id", RequestIDFrom(c),
|
||
"action", "oidc.logout",
|
||
"user_id", userID,
|
||
"idp_logout", deps.OIDCLogoutURL != "", // 不記 URL 本身(非機密但精簡);只記是否連動
|
||
)
|
||
WriteSuccess(c, http.StatusOK, resp)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|