把 visionA-backend 6 個 in-memory store 接到資料庫持久化,範圍=完整 (PG 全接 + session 接 Redis + 交易韌性)。interface / handler 不動, 只加 DB 實作 + 換 wiring,config 未設 DB 時保留 in-memory fallback。 - 塊 0 基礎建設:pgx/v5 連線池 + DatabaseConfig/RedisConfig + golang-migrate runner(embed)+ cmd/migrate + testcontainers 測試基礎建設 - 塊 1 model → Postgres:array 映射、upsert 保留 CreatedAt、faa_object_key、 三維 filter(owner/chip/source)、soft-delete partial index - 塊 2 device → Postgres:partial unique(已刪 serial 可重註冊)、雙狀態欄位 - 塊 3 token → Postgres:pairing_tokens + session_tokens 分表、token_hash 當 PK - 塊 4 userSession → Redis:idle + absolute 雙 TTL 取代 cleanup goroutine (tunnel session 維持 in-memory,yamux handle 不可序列化) - 塊 5 交易/韌性:WithTx helper + 刪 device cascade 撤銷 token(同 tx 原子) + /healthz ping PG/Redis(fail-fast 503)+ pgx error 統一映射(不洩漏 raw error) 降級策略(fail-fast):PG 掉 → 持久資料 API 回 503;Redis 掉 → session 失敗 不自動 fallback in-memory(避免多機 session 不同步)。 DB:PostgreSQL 14.23(gen_random_uuid 內建、無 citext → email 用 lower() unique index)。每塊經 Reviewer 審查 + 真 PG/Redis testcontainers 全量 dbtest 綠燈, in-memory fallback 未受影響。 docs: 同步更新 database.md(schema/config/migration 清單)+ api-spec.md (409/503 錯誤碼、/healthz 新行為、device unpair cascade)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
185 lines
5.2 KiB
Go
185 lines
5.2 KiB
Go
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)
|