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

540 lines
23 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
// fallbackEmailDomain 是 OIDC email claim 缺漏時,用 sub 組 placeholder email 的網域。
//
// 背景Member CenterMC的 id_token 目前不發 email claimASP.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 TLDRFC 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還要連帶登出 IdPMember Centersession」。
// 未設定時欄位 omitempty 不出現、維持「只清本地」的向下相容行為。
type LogoutResponse struct {
Success bool `json:"success"`
// IDPLogout 帶 IdPMC登出資訊供前端觸發 MC 登出(讓使用者能換帳號)。
// nil 時代表未啟用 MC 連動登出。
IDPLogout *IDPLogoutInfo `json:"idp_logout,omitempty"`
}
// IDPLogoutInfo 描述「如何觸發 IdPMember Center登出」。
//
// 為什麼需要 MethodMC 不支援標準 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 = URLGET觸發 MC :7880 登出
// 3. ⚠️ stage MC 跑 master 舊版2026-04-30 imagelogout **不支援 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 是 IdPMC 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 規劃但實際整合在 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。
//
// email fallback放寬 email 必填MC 現況不發 email claimclaims.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.idD1-B
Email: provisionEmail,
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 模式)。
//
// 行為:一律清掉 visionA 自己的 session + cookie原有行為不變
// Idempotentcookie 不存在或 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 登出,
// 讓使用者登出後能換帳號。未設定時不回該欄位、維持「只清本地」舊行為(向下相容)。
//
// TODOMC 補上標準 RP-initiated logoutGET 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/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
}