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