jim800121chen 4d0b870480 feat(visionA-backend): DB 接入 — 6 store 接 PostgreSQL/Redis 持久化(塊 0-5)
把 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>
2026-06-20 18:28:04 +08:00

183 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package auth
import (
"context"
"sync"
"time"
)
// ==========================================================================
// SessionTokenStore
// ==========================================================================
//
// 對齊 security.md §1.3 / visiona-agent-tdd.md §4.3
// - Pairing TokenvAc_ + 32 hex15 min TTL一次性。
// - Session TokenvAs_ + 64 hex90 天 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 為原文 tokencaller 只此一次能拿到)。
Create(ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration) (plaintext string, info *SessionToken, err error)
// Get 依 plaintext 查詢 Session Tokentoken 不存在回 ErrInvalidToken
// 過期回 ErrTokenExpired已撤銷回 ErrTokenRevoked。
//
// 回傳的 SessionToken 為 copycaller 不可直接改內部狀態。
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 keyPhase 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 tokencascade 撤銷,塊 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)