從 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>
201 lines
8.0 KiB
Go
201 lines
8.0 KiB
Go
package usersession
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
)
|
||
|
||
// MinSigningKeyBytes 是 CookieConfig.SigningKey 的最小長度。
|
||
//
|
||
// HMAC-SHA256 安全建議 key 長度 ≥ hash output(32 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 長度 ≥ MinSigningKeyBytes(32 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
|
||
}
|
||
// 其他 error(context 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. 刪除舊 session(store 中不留殘留)
|
||
//
|
||
// 失敗策略:任何步驟失敗都不可讓「舊 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
|
||
}
|