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

201 lines
8.0 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.

package usersession
import (
"context"
"errors"
"fmt"
"net/http"
)
// MinSigningKeyBytes 是 CookieConfig.SigningKey 的最小長度。
//
// HMAC-SHA256 安全建議 key 長度 ≥ hash output32 bytes / 256 bits
// 短於此長度 entropy 不足,可能在離線暴力破解下被還原。
// 由 NewManager 在 startup 階段強制檢查,確保任何進到 production 的設定都安全。
const MinSigningKeyBytes = 32
// Manager 把 Store 與 cookie helper 包成 handler-friendly 的 facade。
//
// OB3 的 middleware 與 OB4 的 auth handler 直接呼叫 Manager 即可,不必各自處理 cookie / store 細節。
//
// 並發安全:完全 delegate 到 Store自身無 mutable state。
type Manager struct {
Store Store
CookieCfg CookieConfig
}
// NewManager 建立 Manager。store / cookieCfg 任一為 nil/zero 不檢查caller 自負,
// 與 internal/oidc.New 的風格一致)— 但 cookieCfg.SigningKey 會在實際 Read/Write 時被驗。
//
// 唯一 startup-time 強制檢查SigningKey 長度 ≥ MinSigningKeyBytes32 bytes
// 不滿足時 panic目的是讓設定錯誤在啟動瞬間就被發現而不是等到第一個 cookie
// 被簽出去之後才在 logs 裡看到 entropy 警告。CookieConfig.SigningKey 為 nil/empty
// 也會在這裡 panic< 32 bytes 的子集)。
func NewManager(store Store, cookieCfg CookieConfig) *Manager {
if len(cookieCfg.SigningKey) < MinSigningKeyBytes {
panic(fmt.Sprintf("usersession.NewManager: %v (got %d bytes, need ≥ %d)",
ErrSigningKeyTooShort, len(cookieCfg.SigningKey), MinSigningKeyBytes))
}
return &Manager{
Store: store,
CookieCfg: cookieCfg,
}
}
// StartSession 產生新 session、寫入 cookie回傳 *Session 副本。
//
// 用途登入成功的時候叫OB4 callback handler
// 注意:剛 Create 的 session UserID/Email 等都還是空字串caller 通常會接著呼叫 UpdateSession
// 把 OIDC claims 填入。
func (m *Manager) StartSession(ctx context.Context, w http.ResponseWriter) (*Session, error) {
sess, err := m.Store.Create(ctx)
if err != nil {
return nil, err
}
if err := WriteCookie(w, m.CookieCfg, sess.ID); err != nil {
// 寫 cookie 失敗 → 把已建好的 session 收掉,避免 store 留下無 cookie 對應的 zombie。
_ = m.Store.Delete(ctx, sess.ID)
return nil, err
}
return sess, nil
}
// GetSession 從 request cookie 解出 sessionID從 store 取出 Session
// 並更新 LastSeenAt透過 Store.Update
//
// 找不到 cookie 或 cookie 無效 → ErrNoSession外部視為「未登入」
// store 中找不到 → ErrNoSession已登出 / 已過期 / store 重啟過)。
//
// 為了 idempotency若 Touch透過 Update失敗仍回傳已取出的 session 與該 error
// 讓 caller 自行決定是否視為認證成功(一般情況下 Update 失敗代表 session 在比對之間被刪,
// 這時應視為未登入)。
func (m *Manager) GetSession(ctx context.Context, r *http.Request) (*Session, error) {
sid, ok := ReadCookie(r, m.CookieCfg)
if !ok {
return nil, ErrNoSession
}
sess, err := m.Store.Get(ctx, sid)
if err != nil {
return nil, err
}
// 更新 LastSeenAt讓 idle timeout 從現在重新計算)。
// 失敗時若是 ErrNoSession代表 session 在 Get 與 Update 之間被刪,回 ErrNoSession。
if err := m.Store.Update(ctx, sess); err != nil {
if errors.Is(err, ErrNoSession) {
return nil, ErrNoSession
}
// 其他 errorcontext cancelled 等)原樣回傳。
return nil, err
}
return sess, nil
}
// UpdateSession 將 caller 修改過的 session 寫回 store。
//
// 用途callback handler 拿到 OIDC claims 後 → UserID/Email/Name 填入 → UpdateSession。
// 同樣會把 LastSeenAt 設成 now。
func (m *Manager) UpdateSession(ctx context.Context, sess *Session) error {
if sess == nil {
return ErrNoSession
}
return m.Store.Update(ctx, sess)
}
// RotateSessionID 用於登入完成後的 session fixation 防護OWASP ASVS V3.2.1)。
//
// 攻擊情境:攻擊者預先取得一個合法 pending session cookie例如自己跑 /api/auth/login
// 拿到 cookie用社交工程誘騙受害者使用這個 cookie 走完 OIDC flow。callback 完成後,
// 攻擊者持有的同一 cookie 也升級成「已登入 session」—— 攻擊者瞬間擁有受害者的帳號。
//
// 防護:登入完成的瞬間 rotate session ID作法是
// 1. 從現有 cookie 讀舊 session必須存在否則回 ErrNoSession
// 2. 在 store 建立新 session新隨機 ID
// 3. 把舊 session 的所有欄位UserID / Email / Name / OIDC tokens / Extra複製到新 session
// 4. 把新 session 寫回 store一次 Update 完成欄位 copy
// 5. 寫新 cookie 覆蓋舊的(瀏覽器舊 cookie 從此無效)
// 6. 刪除舊 sessionstore 中不留殘留)
//
// 失敗策略:任何步驟失敗都不可讓「舊 session 已升級為 logged-in」的狀態持續存在。
// 因此:
// - 步驟 2 失敗store 無法建立)→ 直接回 error舊 session 仍是 pending尚未升級可接受
// - 步驟 4 失敗 → 試著清掉新建的 session回 error
// - 步驟 5 失敗 → 同上,並回 error
// - 步驟 6 失敗 → 不擋cookie 已換、新 session 已生效),但會 swallow 此 error
// (舊 session 在 store 中變 zombie會被 CleanupExpired 收掉)
//
// 必須在「驗 id_token 成功之後、把 user info 寫進 session 之前」呼叫。
// 呼叫者拿到回傳的新 session 後,再 set UserID/Email/Name 並 UpdateSession。
func (m *Manager) RotateSessionID(ctx context.Context, w http.ResponseWriter, r *http.Request) (*Session, error) {
// 步驟 1取舊 session ID不更新 LastSeenAt — 我們即將刪掉它)
oldSID, ok := ReadCookie(r, m.CookieCfg)
if !ok {
return nil, ErrNoSession
}
oldSess, err := m.Store.Get(ctx, oldSID)
if err != nil {
return nil, err
}
// 步驟 2建立新 session拿新隨機 ID
newSess, err := m.Store.Create(ctx)
if err != nil {
return nil, err
}
// 步驟 3複製 OIDC pending state + 已有的使用者欄位callback 中此時 UserID 仍空)。
// CreatedAt / LastSeenAt 保留新 session 的代表「rotation 起點」)。
// ID 不複製(必須是新的 random ID
newSess.UserID = oldSess.UserID
newSess.Email = oldSess.Email
newSess.Name = oldSess.Name
newSess.OIDCState = oldSess.OIDCState
newSess.OIDCNonce = oldSess.OIDCNonce
newSess.OIDCCodeVerifier = oldSess.OIDCCodeVerifier
newSess.AccessToken = oldSess.AccessToken
newSess.IDTokenRaw = oldSess.IDTokenRaw
if len(oldSess.Extra) > 0 {
newSess.Extra = make(map[string]any, len(oldSess.Extra))
for k, v := range oldSess.Extra {
newSess.Extra[k] = v
}
}
// 步驟 4把欄位寫進 store
if err := m.Store.Update(ctx, newSess); err != nil {
// rollback清掉剛建好的新 session不留 zombie
_ = m.Store.Delete(ctx, newSess.ID)
return nil, err
}
// 步驟 5寫新 cookie覆蓋舊的
if err := WriteCookie(w, m.CookieCfg, newSess.ID); err != nil {
_ = m.Store.Delete(ctx, newSess.ID)
return nil, err
}
// 步驟 6刪除舊 session。失敗只 swallow舊 session 已無 cookie 對應,
// 會被 CleanupExpired 清掉;此處 fail 不影響 rotation 已完成的事實)。
_ = m.Store.Delete(ctx, oldSID)
return newSess, nil
}
// EndSession 從 request 取 cookie → 從 store 刪 session → 寫過期 cookie 清掉 browser 端。
//
// 即便 cookie 不存在或 store 中找不到,仍會寫過期 cookie 確保 browser 端清掉
// logout 應該是冪等的,無論之前的狀態為何)。
func (m *Manager) EndSession(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
sid, ok := ReadCookie(r, m.CookieCfg)
if ok {
// Delete 是 no-op-on-missing不會回 ErrNoSession。
if err := m.Store.Delete(ctx, sid); err != nil {
// store 內部錯誤context cancelled 等)— 仍要清 cookie但回 error 給 caller log。
ClearCookie(w, m.CookieCfg)
return err
}
}
ClearCookie(w, m.CookieCfg)
return nil
}