package usersession import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/redis/go-redis/v9" ) // redisKeyPrefix 是所有 user session key 的命名空間前綴。 // // 加前綴的理由: // - 與同一個 Redis db 內其他用途的 key 隔離(即使 RedisConfig.DB 已分 index,前綴再加一層保險)。 // - 方便用 SCAN MATCH "usersession:*" 觀測 / 排查(CleanupExpired 也靠它列出 key)。 const redisKeyPrefix = "usersession:" // RedisUserSessionStore 是 Store 的 Redis 實作。 // // 設計重點(對齊 docs/autoflow/04-architecture/database.md §2.7): // // - **雙 TTL** 取代 InMemoryStore 的手動 CleanupExpired goroutine: // idle TTL(每次 Update 續期)+ absolute TTL(建立後固定上限,不續期)。 // 做法:把 session JSON 存進一個 Redis key,key 的 Redis TTL 設為 // min(idleTTL, 距 absolute deadline 的剩餘時間)。 // 這樣 key 在「閒置超過 idle」或「建立超過 absolute」任一條件成立時都會被 Redis 自動清掉, // 不需要 background goroutine 掃描。 // // - **absolute deadline 精準防護**:CreatedAt 存進 value,Get 時再算一次 // now - CreatedAt > absolute,雙重保險(即使因為時鐘 / 續期計算誤差讓 key 多活一瞬間, // Get 仍會視為過期回 ErrNoSession 並順手刪除)。 // // - **Extra map JSON 序列化**:整個 Session 用 encoding/json 序列化進 value, // Extra map[string]any 自然被序列化。caller 須自我約束放可 JSON 序列化的型別 // (usersession.go Session.Extra 註解已載明此約束)。 // // 並發安全:所有方法都是單一 Redis 指令或 store 內無共享 mutable state,go-redis client // 本身並發安全。 // // 安全:OIDCCodeVerifier / AccessToken / IDTokenRaw 等敏感欄位雖序列化進 Redis value, // 但不會進入任何 log(store 內不 log value)。 type RedisUserSessionStore struct { client redis.UniversalClient // idleTTL / absoluteTTL 在建構時固定(來自 UserSessionConfig.IdleTTL / AbsoluteTTL)。 // // 與 InMemoryStore.CleanupExpired(ctx, idle, abs) 把 timeout 當參數傳不同:Redis 版的 TTL // 必須在「寫入 key 當下」就決定(Redis TTL 是 per-key 設定),所以 store 自己持有這兩個值。 idleTTL time.Duration absoluteTTL time.Duration } // 編譯期確認 RedisUserSessionStore 滿足 Store interface。 var _ Store = (*RedisUserSessionStore)(nil) // redisSession 是寫入 Redis value 的序列化結構。 // // 用獨立 struct(而非直接序列化 Session)的理由: // - 明確控制哪些欄位落地、用穩定的 JSON key(Session struct 沒有 json tag, // 直接序列化會用 Go 欄位名,未來改欄位名會破壞既有 value 相容性)。 // - 時間用 UnixNano 存,跨程序 / 跨機器無時區歧義,且 Get 算 absolute deadline 直接做整數運算。 type redisSession struct { ID string `json:"id"` UserID string `json:"uid,omitempty"` Email string `json:"email,omitempty"` Name string `json:"name,omitempty"` CreatedAtUnix int64 `json:"created_at"` LastSeenAtUnix int64 `json:"last_seen_at"` OIDCState string `json:"oidc_state,omitempty"` OIDCNonce string `json:"oidc_nonce,omitempty"` OIDCCodeVerifier string `json:"oidc_cv,omitempty"` AccessToken string `json:"access_token,omitempty"` IDTokenRaw string `json:"id_token_raw,omitempty"` Extra map[string]any `json:"extra,omitempty"` } // NewRedisUserSessionStore 建立 Redis-backed 的 user session store。 // // client 為已連線的 go-redis client(main.go 傳 db.RedisClient.Client();測試傳 miniredis client)。 // idleTTL / absoluteTTL 來自 cfg.UserSession.IdleTTL(預設 24h)/ AbsoluteTTL(預設 168h)。 // // idleTTL / absoluteTTL <= 0 視為「該維度不過期」: // - idleTTL <= 0 → key 不設 idle 上限(仍受 absolute 限制)。 // - absoluteTTL <= 0 → 無 absolute 上限(key 只受 idle 限制)。 // - 兩者皆 <= 0 → key 永不過期(PERSIST 語意;正式環境不應如此設定,僅測試 / 特例用)。 func NewRedisUserSessionStore(client redis.UniversalClient, idleTTL, absoluteTTL time.Duration) *RedisUserSessionStore { return &RedisUserSessionStore{ client: client, idleTTL: idleTTL, absoluteTTL: absoluteTTL, } } func redisKey(id string) string { return redisKeyPrefix + id } // effectiveTTL 計算「寫入 key 當下」應設的 Redis TTL。 // // now = 現在時間 // createdAt = session 建立時間(absolute deadline 的錨點) // // 回傳值語意(go-redis SET 的 expiration 參數): // - > 0 → 設這個 TTL // - == 0 → 不過期(redis.KeepTTL 不適用 SET 新值,故用特殊處理:呼叫端用 0 代表 PERSIST) // // 計算:取 min(idleTTL, 距 absolute deadline 的剩餘)。 // - idleTTL <= 0:不考慮 idle,只剩 absolute remaining。 // - absoluteTTL <= 0:不考慮 absolute,只剩 idleTTL。 // - 兩者皆 <= 0:回 0(永不過期)。 // - 若有 absolute 上限且 absolute remaining <= 0(已到 / 超過 absolute):回 <=0。 // // ⚠️ 回傳 0 有兩種語意,呼叫端(save)**必須**搭配 s.absoluteTTL 才能區分: // - s.absoluteTTL == 0 → 0 代表合法 PERSIST(永不過期)。 // - s.absoluteTTL > 0 → 0(或 <0)代表已到 absolute deadline、應視為過期。 // // 因此 save 用 `ttl <= 0 && s.absoluteTTL > 0` 判定過期,而非只看 `ttl < 0`, // 避免 absRemaining 恰好對齊到 0 ns 時繞過 absolute 上限。 func (s *RedisUserSessionStore) effectiveTTL(now, createdAt time.Time) time.Duration { hasIdle := s.idleTTL > 0 hasAbs := s.absoluteTTL > 0 switch { case !hasIdle && !hasAbs: return 0 // 永不過期 case hasIdle && !hasAbs: return s.idleTTL case !hasIdle && hasAbs: return createdAt.Add(s.absoluteTTL).Sub(now) default: absRemaining := createdAt.Add(s.absoluteTTL).Sub(now) if absRemaining < s.idleTTL { return absRemaining } return s.idleTTL } } // save 序列化 redisSession 並用計算出的 TTL 寫入 Redis。 // // expectExists:true 時用 SET ... XX(key 必須已存在才寫,供 Update 防「順便建立」); // false 時普通 SET(供 Create)。 // // 回傳 (false, nil) 代表 XX 條件不成立(key 不存在),由 Update 翻譯成 ErrNoSession。 func (s *RedisUserSessionStore) save(ctx context.Context, rs *redisSession, now time.Time, expectExists bool) (ok bool, err error) { ttl := s.effectiveTTL(now, time.Unix(0, rs.CreatedAtUnix)) // effectiveTTL == 0 的兩種語意必須分流,否則「有 absolute 上限但 absRemaining 恰好 // 對齊到 0 ns」會被誤判成 PERSIST,讓該 key 永不過期、繞過 absolute 上限: // - s.absoluteTTL == 0 時,0 代表合法 PERSIST(idle+absolute 皆停用)→ 放行寫入。 // - s.absoluteTTL > 0 時,<=0 代表已到(或超過)absolute deadline → 不應寫入, // 視為過期(Update 翻譯成 ErrNoSession)。用 <= 同時涵蓋 <0 與 ==0。 if ttl <= 0 && s.absoluteTTL > 0 { // 已到 / 超過 absolute deadline,不應再寫入(Update 視為過期)。 return false, ErrSessionExpired } payload, err := json.Marshal(rs) if err != nil { return false, fmt.Errorf("usersession: marshal session: %w", err) } // ttl == 0 → 永不過期(redis SET 不帶 expiration)。 args := redis.SetArgs{} if ttl > 0 { args.TTL = ttl } if expectExists { args.Mode = "XX" } // SetArgs 回傳 redis.Nil 代表 XX 不成立(key 不存在)。 res, err := s.client.SetArgs(ctx, redisKey(rs.ID), payload, args).Result() if err != nil { if errors.Is(err, redis.Nil) { return false, nil // XX 條件不成立 } return false, fmt.Errorf("usersession: redis set: %w", err) } _ = res return true, nil } // Create 實作 Store.Create。產生隨機 ID 的新 session 並寫入 Redis(TTL = idle 與 absolute 取小)。 func (s *RedisUserSessionStore) 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, } rs := toRedisSession(sess) ok, err := s.save(ctx, rs, now, false) if err != nil { return nil, err } if !ok { // 普通 SET(非 XX)理論上不會回 !ok;保險處理為 internal error。 return nil, ErrInvalidConfig } return copySessionValue(sess), nil } // Get 實作 Store.Get。 // // 找不到(含已被 Redis TTL 清掉)回 ErrNoSession。**不**續期 idle TTL(對齊 InMemoryStore: // Get 不更新 LastSeenAt,避免無條件刷新延長 idle window)。 // // absolute 精準防護:取出後再算一次 now - CreatedAt > absolute,超過則 Delete + 回 ErrNoSession。 func (s *RedisUserSessionStore) Get(ctx context.Context, id string) (*Session, error) { if err := ctx.Err(); err != nil { return nil, err } if id == "" { return nil, ErrNoSession } val, err := s.client.Get(ctx, redisKey(id)).Result() if err != nil { if errors.Is(err, redis.Nil) { return nil, ErrNoSession } return nil, fmt.Errorf("usersession: redis get: %w", err) } var rs redisSession if err := json.Unmarshal([]byte(val), &rs); err != nil { return nil, fmt.Errorf("usersession: unmarshal session: %w", err) } sess := fromRedisSession(&rs) // absolute deadline 精準防護(雙重保險,理論上 key 早被 TTL 清掉)。 if s.absoluteTTL > 0 && nowFunc().Sub(sess.CreatedAt) > s.absoluteTTL { _ = s.client.Del(ctx, redisKey(id)).Err() return nil, ErrNoSession } return sess, nil } // Update 實作 Store.Update。 // // 把 caller 改過的 Session 寫回 Redis、LastSeenAt 設 now、並續期 idle TTL(同時仍受 absolute 上限)。 // 找不到 ID(含已過期被清)回 ErrNoSession,不會「順便建立」(用 SET XX 達成)。 func (s *RedisUserSessionStore) Update(ctx context.Context, sess *Session) error { if err := ctx.Err(); err != nil { return err } if sess == nil || sess.ID == "" { return ErrNoSession } now := nowFunc() // 先確認既有 key 存在並取得 CreatedAt(caller 傳進來的 CreatedAt 可能被誤改; // 以 store 內既有值為準,對齊 InMemoryStore「Update 不改 CreatedAt」語意)。 existing, err := s.client.Get(ctx, redisKey(sess.ID)).Result() if err != nil { if errors.Is(err, redis.Nil) { return ErrNoSession } return fmt.Errorf("usersession: redis get (update): %w", err) } var existingRS redisSession if err := json.Unmarshal([]byte(existing), &existingRS); err != nil { return fmt.Errorf("usersession: unmarshal session (update): %w", err) } // absolute 已過 → 視為不存在(順手刪)。 createdAt := time.Unix(0, existingRS.CreatedAtUnix) if s.absoluteTTL > 0 && now.Sub(createdAt) > s.absoluteTTL { _ = s.client.Del(ctx, redisKey(sess.ID)).Err() return ErrNoSession } rs := toRedisSession(sess) rs.CreatedAtUnix = existingRS.CreatedAtUnix // 保留既有 CreatedAt rs.LastSeenAtUnix = now.UnixNano() // 續期:LastSeenAt = now ok, err := s.save(ctx, rs, now, true) // XX:key 必須仍存在 if err != nil { if errors.Is(err, ErrSessionExpired) { // effectiveTTL <= 0 且有 absolute 上限:已到 / 超過 absolute,視為不存在。 _ = s.client.Del(ctx, redisKey(sess.ID)).Err() return ErrNoSession } return err } if !ok { // XX 不成立:key 在 Get 與 SET 之間剛好過期。 return ErrNoSession } // 把更新後的 LastSeenAt 反映回 caller pointer(對齊 InMemoryStore.Update)。 sess.LastSeenAt = time.Unix(0, rs.LastSeenAtUnix) sess.CreatedAt = createdAt return nil } // Delete 實作 Store.Delete。不存在為 no-op(DEL 對不存在 key 回 0,不報錯)。 func (s *RedisUserSessionStore) Delete(ctx context.Context, id string) error { if err := ctx.Err(); err != nil { return err } if id == "" { return nil } if err := s.client.Del(ctx, redisKey(id)).Err(); err != nil { return fmt.Errorf("usersession: redis del: %w", err) } return nil } // CleanupExpired 實作 Store.CleanupExpired。 // // Redis 版**靠 key TTL 自動過期**,不需 background 掃描;此方法在 Redis 模式下基本是 no-op, // 保留只為滿足 Store interface(in-memory fallback 仍會用到)。 // // 回傳 (0, nil):Redis 已自動清掉過期 key,無「本次清除數量」可報。 // 參數 idleTimeout / absoluteTimeout 被忽略(TTL 已在寫入時依 store 持有的 idle/absolute 設定)。 func (s *RedisUserSessionStore) CleanupExpired(ctx context.Context, idleTimeout, absoluteTimeout time.Duration) (int, error) { if err := ctx.Err(); err != nil { return 0, err } // no-op:Redis TTL 負責過期。 return 0, nil } // toRedisSession 把 Session 轉成可序列化的 redisSession(時間轉 UnixNano)。 func toRedisSession(sess *Session) *redisSession { return &redisSession{ ID: sess.ID, UserID: sess.UserID, Email: sess.Email, Name: sess.Name, CreatedAtUnix: sess.CreatedAt.UnixNano(), LastSeenAtUnix: sess.LastSeenAt.UnixNano(), OIDCState: sess.OIDCState, OIDCNonce: sess.OIDCNonce, OIDCCodeVerifier: sess.OIDCCodeVerifier, AccessToken: sess.AccessToken, IDTokenRaw: sess.IDTokenRaw, Extra: sess.Extra, } } // fromRedisSession 把序列化結構還原成 Session(UnixNano 轉 time.Time)。 func fromRedisSession(rs *redisSession) *Session { return &Session{ ID: rs.ID, UserID: rs.UserID, Email: rs.Email, Name: rs.Name, CreatedAt: time.Unix(0, rs.CreatedAtUnix), LastSeenAt: time.Unix(0, rs.LastSeenAtUnix), OIDCState: rs.OIDCState, OIDCNonce: rs.OIDCNonce, OIDCCodeVerifier: rs.OIDCCodeVerifier, AccessToken: rs.AccessToken, IDTokenRaw: rs.IDTokenRaw, Extra: rs.Extra, } } // copySessionValue 製作 Session 的副本(含 Extra map 深一層),避免 caller 改到 store 回傳值。 // // 與 InMemoryStore.copySession 等價,但為 free function(Redis store 無需持鎖)。 func copySessionValue(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 }