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 }