把 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>
183 lines
5.9 KiB
Go
183 lines
5.9 KiB
Go
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)
|