package usersession import ( "context" "crypto/rand" "encoding/base64" "sync" "time" ) // sessionIDBytes 是隨機 session ID 的 byte 數。 // 32 bytes → base64url 後 43 字元(無 padding),對齊 oidc-tdd.md §5.1 / §6.2 慣例。 const sessionIDBytes = 32 // nowFunc 是時間取得函式,測試可置換。預設 time.Now。 // 注意:此 package 內所有時間判斷都應透過此函式以方便測試。 var nowFunc = time.Now // InMemoryStore 是 Store 的 process-local map 實作。 // // 雛形 Phase 0.6 用此實作;backend 重啟即所有使用者重登(內部測試者可接受)。 // Phase 1 換 RedisStore / DBStore 接同一個 interface 即可,handler 不必改。 // // 並發安全:以 sync.RWMutex 保護 sessions map。 type InMemoryStore struct { mu sync.RWMutex sessions map[string]*Session } // NewInMemoryStore 建立一個空的 InMemoryStore。 func NewInMemoryStore() *InMemoryStore { return &InMemoryStore{ sessions: make(map[string]*Session), } } // generateSessionID 產生 32 bytes 隨機值,base64url(無 padding)編碼。 // 使用 crypto/rand,失敗回 error(系統熵源不足才會發生)。 func generateSessionID() (string, error) { b := make([]byte, sessionIDBytes) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } // Create 實作 Store.Create。產生隨機 ID 並寫入 store。 // // 不接受 context 取消(map 寫入是同步、瞬時操作),但保留 context 參數以便未來換 Redis。 func (s *InMemoryStore) Create(ctx context.Context) (*Session, error) { if err := ctx.Err(); err != nil { return nil, err } id, err := generateSessionID() if err != nil { return nil, err } now := nowFunc() sess := &Session{ ID: id, CreatedAt: now, LastSeenAt: now, } s.mu.Lock() defer s.mu.Unlock() // 理論上 32 bytes random 撞 ID 機率近乎 0(2^-256),保險檢查避免覆蓋。 if _, exists := s.sessions[id]; exists { return nil, ErrInvalidConfig // 用 ErrInvalidConfig 不準確;視為「天文事件」當 internal error 處理 } s.sessions[id] = sess // 回傳副本,避免 caller 直接改到 store 內的 pointer state(必須走 Update)。 return s.copySession(sess), nil } // Get 實作 Store.Get。 // // 找不到回 ErrNoSession。**不**自動更新 LastSeenAt — 由 caller 決定。 // 回傳的是 store 內 session 的「副本」,避免外部 mutation race。 func (s *InMemoryStore) Get(ctx context.Context, id string) (*Session, error) { if err := ctx.Err(); err != nil { return nil, err } if id == "" { return nil, ErrNoSession } s.mu.RLock() defer s.mu.RUnlock() sess, ok := s.sessions[id] if !ok { return nil, ErrNoSession } return s.copySession(sess), nil } // Update 實作 Store.Update。 // // 將 caller 修改過的 Session 寫回 store 並把 LastSeenAt 設為 now。 // 找不到 ID 對應的 session 回 ErrNoSession(不會「順便建立」,避免被當成 Create 用)。 func (s *InMemoryStore) Update(ctx context.Context, sess *Session) error { if err := ctx.Err(); err != nil { return err } if sess == nil || sess.ID == "" { return ErrNoSession } s.mu.Lock() defer s.mu.Unlock() if _, ok := s.sessions[sess.ID]; !ok { return ErrNoSession } // 寫入 store 一份新的副本(CreatedAt 保留 caller 的值;caller 不應該改它,但即使改了我們不防) stored := s.copySession(sess) stored.LastSeenAt = nowFunc() s.sessions[sess.ID] = stored // 把更新後的 LastSeenAt 反映回 caller pointer(讓 caller 拿到最新值,不必再 Get) sess.LastSeenAt = stored.LastSeenAt return nil } // Delete 實作 Store.Delete。不存在為 no-op。 func (s *InMemoryStore) Delete(ctx context.Context, id string) error { if err := ctx.Err(); err != nil { return err } if id == "" { return nil } s.mu.Lock() defer s.mu.Unlock() delete(s.sessions, id) return nil } // CleanupExpired 實作 Store.CleanupExpired。 // // idleTimeout:now - LastSeenAt > idleTimeout // absoluteTimeout:now - CreatedAt > absoluteTimeout // 任一成立即移除。 // // 注意:使用 > 而非 >=,避免邊界閃爍(剛好等於 timeout 時不刪)。 func (s *InMemoryStore) CleanupExpired(ctx context.Context, idleTimeout, absoluteTimeout time.Duration) (int, error) { if err := ctx.Err(); err != nil { return 0, err } now := nowFunc() s.mu.Lock() defer s.mu.Unlock() removed := 0 for id, sess := range s.sessions { idleExpired := idleTimeout > 0 && now.Sub(sess.LastSeenAt) > idleTimeout absoluteExpired := absoluteTimeout > 0 && now.Sub(sess.CreatedAt) > absoluteTimeout if idleExpired || absoluteExpired { delete(s.sessions, id) removed++ } } return removed, nil } // copySession 製作 Session 的淺副本(Extra map 也複製一層,避免外部修改)。 // // 必須在持鎖下呼叫,或 caller 確認 src 不被併發修改。 func (s *InMemoryStore) copySession(src *Session) *Session { dst := *src if src.Extra != nil { dst.Extra = make(map[string]any, len(src.Extra)) for k, v := range src.Extra { dst.Extra[k] = v } } return &dst } // Len 回傳當前 session 數量(測試 / 觀測用)。 func (s *InMemoryStore) Len() int { s.mu.RLock() defer s.mu.RUnlock() return len(s.sessions) }