package auth import ( "context" "sync" "time" ) // InMemoryPairingStore 是 PairingStore 的記憶體實作,用於 Phase 0 雛形與單元測試。 // // 設計要點: // - 以 plaintext token 為 map key(雛形圖簡;Phase 1 的 PostgresPairingStore 會改存 hash) // - sync.RWMutex 確保並發安全 // - 一次性語意:MarkUsed 後 Validate 會回 ErrTokenUsed // - TTL 語意:超過 ExpiresAt 後 Validate 回 ErrTokenExpired;CleanupExpired 會移除 type InMemoryPairingStore struct { mu sync.RWMutex tokens map[string]*PairingToken // key = plaintext token } // NewInMemoryPairingStore 建立一個空的記憶體 PairingStore。 func NewInMemoryPairingStore() *InMemoryPairingStore { return &InMemoryPairingStore{ tokens: make(map[string]*PairingToken), } } // Create 產生並保存一個新 pairing token。 // // ttl 為相對存活時間;內部以目前時間 + ttl 算出 ExpiresAt。 // 若 ttl <= 0,則 ExpiresAt 保持 nil(永不過期;僅測試 / Phase 1 特殊情境使用)。 func (s *InMemoryPairingStore) Create( ctx context.Context, userID string, ttl time.Duration, ) (string, *PairingToken, error) { plaintext, err := GeneratePairingToken() if err != nil { return "", nil, err } now := time.Now().UTC() info := &PairingToken{ Plaintext: plaintext, TokenHash: HashToken(plaintext), UserID: userID, Kind: KindPairing, CreatedAt: now, } if ttl > 0 { expires := now.Add(ttl) info.ExpiresAt = &expires } s.mu.Lock() s.tokens[plaintext] = info s.mu.Unlock() // 回傳的 info 給 caller 用(不含 Plaintext 避免誤寫入 log)。 // 但為了讓 caller 能立刻傳給前端顯示一次,Plaintext 保留。 // 呼叫方有責任不記錄 info.Plaintext 到持久化日誌。 return plaintext, info, nil } // Validate 檢查 token 是否存在且可用(未過期、未消費、未撤銷)。 func (s *InMemoryPairingStore) Validate(ctx context.Context, token string) (*PairingToken, error) { s.mu.RLock() info, ok := s.tokens[token] s.mu.RUnlock() if !ok { return nil, ErrInvalidToken } if info.IsRevoked() { return nil, ErrTokenRevoked } if info.IsUsed() { return nil, ErrTokenUsed } if info.IsExpired(time.Now().UTC()) { return nil, ErrTokenExpired } // 回傳 copy 避免 caller 誤改內部狀態(map value 是 pointer,複製 struct)。 cp := *info return &cp, nil } // MarkUsed 將 token 標記為已消費,並綁定 deviceID。 // // 若 token 不存在回 ErrInvalidToken;若已標記過則為 no-op(冪等)。 func (s *InMemoryPairingStore) MarkUsed(ctx context.Context, token, deviceID string) error { s.mu.Lock() defer s.mu.Unlock() info, ok := s.tokens[token] if !ok { return ErrInvalidToken } if info.UsedAt != nil { // 已使用 — 冪等回 nil,但不覆寫 deviceID return nil } now := time.Now().UTC() info.UsedAt = &now info.DeviceID = deviceID return nil } // Revoke 撤銷一個 token(Validate 後會回 ErrTokenRevoked)。 func (s *InMemoryPairingStore) Revoke(ctx context.Context, token string) error { s.mu.Lock() defer s.mu.Unlock() info, ok := s.tokens[token] if !ok { return ErrInvalidToken } if info.RevokedAt != nil { return nil // 冪等 } now := time.Now().UTC() info.RevokedAt = &now return nil } // List 回傳指定 user 的所有 pairing token(含已使用 / 撤銷)。 // // 注意:回傳的 slice 為 copy,但 Plaintext 欄位也被複製 — Caller 應避免記錄。 func (s *InMemoryPairingStore) List(ctx context.Context, userID string) ([]*PairingToken, error) { s.mu.RLock() defer s.mu.RUnlock() out := make([]*PairingToken, 0) for _, info := range s.tokens { if info.UserID == userID { cp := *info out = append(out, &cp) } } return out, nil } // RevokeByDevice 撤銷某 device 名下所有「尚未撤銷」的 pairing token(cascade 撤銷,塊 5.2)。 // // in-memory 對齊 Postgres RevokeByDeviceTx 語意:撤所有 DeviceID == deviceID 且未撤銷的 token, // 回傳實際撤銷數。空 deviceID 不撤任何 token(對齊 Postgres)。 // // in-memory 無交易概念:delete device 與本方法在 unpair coordinator 內依序執行,雖非原子, // 但 in-memory 為單機 local-dev fallback,行為一致性(刪 device 後 token 也撤)已滿足。 func (s *InMemoryPairingStore) 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 的 token;回傳移除數量。 // // 通常由 background goroutine 週期性呼叫(例:每 1 分鐘)。 func (s *InMemoryPairingStore) 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.IsExpired(now) { delete(s.tokens, k) removed++ } } return removed, nil } // 編譯時檢查:確保 InMemoryPairingStore 實作 PairingStore。 var _ PairingStore = (*InMemoryPairingStore)(nil)