package auth import ( "context" "sync" "time" ) // ========================================================================== // SessionTokenStore // ========================================================================== // // 對齊 security.md §1.3 / visiona-agent-tdd.md §4.3: // - Pairing Token(vAc_ + 32 hex)15 min TTL,一次性。 // - Session Token(vAs_ + 64 hex)90 天 TTL,長期可撤銷。 // // 本 store 負責「Pairing → Session」交換後發出的 Session Token 生命週期管理。 // 雛形(Phase 0)以 in-memory map 持有;Phase 1 換為 Postgres 時維持介面。 // // 注意:雛形 remote-proxy 目前只做 token 格式驗證(見 relay/server.go // isAcceptableToken),**不會**實際查 SessionTokenStore。這是刻意的雛形取捨, // 對應 visiona-agent-tdd.md 的「選項 A」。Phase 1 會新增 // `GET /internal/session-token/:token` 讓 remote-proxy 拉驗證。 // SessionTokenTTL 是 Session Token 的預設存活時間(對齊 security.md §1.3)。 const SessionTokenTTL = 90 * 24 * time.Hour // SessionTokenStore 管理 Session Token 的生命週期。 // // 實作必須是 goroutine-safe;雛形使用 InMemorySessionTokenStore。 type SessionTokenStore interface { // Create 產生並保存一個新的 Session Token。 // // ttl 為相對存活時間;若 <= 0 視為「無過期時間」。 // plaintext 為原文 token(caller 只此一次能拿到)。 Create(ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration) (plaintext string, info *SessionToken, err error) // Get 依 plaintext 查詢 Session Token;token 不存在回 ErrInvalidToken, // 過期回 ErrTokenExpired,已撤銷回 ErrTokenRevoked。 // // 回傳的 SessionToken 為 copy,caller 不可直接改內部狀態。 Get(ctx context.Context, plaintext string) (*SessionToken, error) // Revoke 撤銷一個 Session Token;之後 Get 會回 ErrTokenRevoked。 // 若 token 不存在回 ErrInvalidToken;已撤銷為冪等(回 nil)。 Revoke(ctx context.Context, plaintext string) error // CleanupExpired 移除所有已過期的 token,回傳移除數量。 // 由 background goroutine 週期性呼叫;雛形暫無呼叫處。 CleanupExpired(ctx context.Context, now time.Time) (removed int, err error) } // ========================================================================== // InMemorySessionTokenStore // ========================================================================== // InMemorySessionTokenStore 是 SessionTokenStore 的雛形記憶體實作。 // // 設計要點(刻意對齊 InMemoryPairingStore 風格): // - 以 plaintext token 為 map key(Phase 1 改 hash) // - sync.RWMutex 保護並發存取 // - ExpiresAt 為 nil 代表永不過期 type InMemorySessionTokenStore struct { mu sync.RWMutex tokens map[string]*SessionToken // key = plaintext token } // NewInMemorySessionTokenStore 建立一個空的記憶體 SessionTokenStore。 func NewInMemorySessionTokenStore() *InMemorySessionTokenStore { return &InMemorySessionTokenStore{ tokens: make(map[string]*SessionToken), } } // Create 產生並保存一個新 Session Token。 // // parentTokenHash 為升級來源(通常是 Pairing Token 的 hash),方便 Phase 1 // 做稽核追蹤;雛形 caller 傳空字串也可以。 func (s *InMemorySessionTokenStore) Create( ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration, ) (string, *SessionToken, error) { plaintext, err := GenerateSessionToken() if err != nil { return "", nil, err } now := time.Now().UTC() info := &SessionToken{ Plaintext: plaintext, TokenHash: HashToken(plaintext), UserID: userID, DeviceID: deviceID, ParentTokenHash: parentTokenHash, CreatedAt: now, } if ttl > 0 { expires := now.Add(ttl) info.ExpiresAt = &expires } s.mu.Lock() s.tokens[plaintext] = info s.mu.Unlock() return plaintext, info, nil } // Get 查詢 Session Token;回傳前會檢查過期 / 撤銷狀態。 func (s *InMemorySessionTokenStore) Get(ctx context.Context, plaintext string) (*SessionToken, error) { s.mu.RLock() info, ok := s.tokens[plaintext] s.mu.RUnlock() if !ok { return nil, ErrInvalidToken } if info.RevokedAt != nil { return nil, ErrTokenRevoked } if info.ExpiresAt != nil && time.Now().UTC().After(*info.ExpiresAt) { return nil, ErrTokenExpired } cp := *info return &cp, nil } // Revoke 撤銷 Session Token;之後 Get 會回 ErrTokenRevoked。 func (s *InMemorySessionTokenStore) Revoke(ctx context.Context, plaintext string) error { s.mu.Lock() defer s.mu.Unlock() info, ok := s.tokens[plaintext] if !ok { return ErrInvalidToken } if info.RevokedAt != nil { return nil // 冪等 } now := time.Now().UTC() info.RevokedAt = &now return nil } // RevokeByDevice 撤銷某 device 名下所有「尚未撤銷」的 session token(cascade 撤銷,塊 5.2)。 // // in-memory 對齊 Postgres RevokeByDeviceTx 語意:撤所有 DeviceID == deviceID 且未撤銷的 token, // 回傳實際撤銷數。空 deviceID 不撤任何 token。 func (s *InMemorySessionTokenStore) RevokeByDevice(ctx context.Context, deviceID string) (int, error) { if deviceID == "" { return 0, nil } s.mu.Lock() defer s.mu.Unlock() now := time.Now().UTC() revoked := 0 for _, info := range s.tokens { if info.DeviceID == deviceID && info.RevokedAt == nil { info.RevokedAt = &now revoked++ } } return revoked, nil } // CleanupExpired 移除所有已過期(ExpiresAt < now)的 token。 func (s *InMemorySessionTokenStore) CleanupExpired(ctx context.Context, now time.Time) (int, error) { s.mu.Lock() defer s.mu.Unlock() removed := 0 for k, info := range s.tokens { if info.ExpiresAt != nil && now.After(*info.ExpiresAt) { delete(s.tokens, k) removed++ } } return removed, nil } // 編譯時檢查:確保 InMemorySessionTokenStore 實作 SessionTokenStore。 var _ SessionTokenStore = (*InMemorySessionTokenStore)(nil)