visionA/visionA-backend/internal/auth/postgres_session_token_store.go
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

220 lines
7.6 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.

// PostgresSessionTokenStore 是 SessionTokenStore 的 PostgreSQL 持久層實作DB 接入塊 3
//
// 與 InMemorySessionTokenStore 實作相同的 SessionTokenStore interface讓 main.go 在
// dbPool != nil 時無痛切換、呼叫端internal/api/pairing.go 的 Create / Revoke一行都不需改。
//
// 對齊:
// - database.md §2.4SessionToken struct、§4session_tokens 表 schema
// - migrations/0003_create_token_tables.up.sqlsession_tokens 表)
//
// ── 關鍵改動plaintext → token_hash 當 PK同 PostgresPairingStore──
//
// Get / Revoke 接收 plaintext內部先 HashToken(plaintext) 再以 hash 查詢。
// 呼叫端統一傳 plaintext已 grep 確認pairing.go 的 SessionTokenStore.Create 用回傳的
// plaintext、Revoke(ctx, plaintext) 傳 plaintext目前無其他 production Get 呼叫端)。
// DB 永不存明文 tokensecurity.md §1.3)。
//
// 語意對齊 in-memory見 session_token.go
// - SessionToken 無 used_at非一次性、無 kind。
// - Get 狀態優先序revoked → expired與 in-memory 一致);不存在回 ErrInvalidToken。
// - Revoke 冪等:未撤銷 → 寫 revoked_at已撤銷 → no-op nil不存在 → ErrInvalidToken。
// - CleanupExpiredDELETE 所有 expires_at < now 的列,回刪除數。
// - parent_token_hash 為稽核鏈欄位(升級來源 pairing token 的 hash原樣存取。
package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"visiona-backend/internal/db"
)
// PostgresSessionTokenStore 是 session token 的 PostgreSQL 持久層實作。
type PostgresSessionTokenStore struct {
pool *pgxpool.Pool
}
// NewPostgresSessionTokenStore 建立一個以 pgxpool 為後端的 SessionTokenStore。
func NewPostgresSessionTokenStore(pool *pgxpool.Pool) *PostgresSessionTokenStore {
return &PostgresSessionTokenStore{pool: pool}
}
// 編譯時檢查:確保 PostgresSessionTokenStore 實作 SessionTokenStore。
var _ SessionTokenStore = (*PostgresSessionTokenStore)(nil)
// sessionColumns 是 SELECT 共用欄位清單(順序必須與 scanSessionToken 對齊)。
const sessionColumns = `token_hash, user_id, device_id, parent_token_hash,
created_at, expires_at, revoked_at`
// Create 產生並保存一個新 session token。
//
// ttl <= 0 時 ExpiresAt 保持 NULL永不過期。parentTokenHash 可為空(雛形 caller
// 回傳的 info.Plaintext 保留原文供 caller 一次性使用DB 不存)。
func (s *PostgresSessionTokenStore) 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,
}
var expiresAt any // nil → DB NULL
if ttl > 0 {
expires := now.Add(ttl)
info.ExpiresAt = &expires
expiresAt = expires
}
// device_id NOT NULL空字串無法寫進 UUID 欄位,會在此回 DB error符合 schema 約束)。
// parent_token_hash nullable空字串轉 NULL。
var parentArg any
if parentTokenHash != "" {
parentArg = parentTokenHash
}
const q = `INSERT INTO session_tokens
(token_hash, user_id, device_id, parent_token_hash, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)`
if _, err := s.pool.Exec(ctx, q,
info.TokenHash, info.UserID, info.DeviceID, parentArg, info.CreatedAt, expiresAt,
); err != nil {
return "", nil, fmt.Errorf("auth: pg session Create: %w", err)
}
return plaintext, info, nil
}
// Get 依 plaintext 查詢 session token內部 HashToken 後查。
//
// 狀態優先序對齊 in-memoryrevoked → expired。不存在回 ErrInvalidToken。
func (s *PostgresSessionTokenStore) Get(ctx context.Context, plaintext string) (*SessionToken, error) {
hash := HashToken(plaintext)
const q = `SELECT ` + sessionColumns + `
FROM session_tokens WHERE token_hash = $1`
row := s.pool.QueryRow(ctx, q, hash)
info, err := scanSessionToken(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrInvalidToken
}
if err != nil {
return nil, fmt.Errorf("auth: pg session Get: %w", err)
}
if info.RevokedAt != nil {
return nil, ErrTokenRevoked
}
if info.ExpiresAt != nil && time.Now().UTC().After(*info.ExpiresAt) {
return nil, ErrTokenExpired
}
return info, nil
}
// Revoke 撤銷 session token之後 Get 回 ErrTokenRevoked
//
// 冪等:已撤銷 → no-op nil不存在 → ErrInvalidToken。
func (s *PostgresSessionTokenStore) Revoke(ctx context.Context, plaintext string) error {
hash := HashToken(plaintext)
const q = `UPDATE session_tokens
SET revoked_at = now()
WHERE token_hash = $1 AND revoked_at IS NULL`
tag, err := s.pool.Exec(ctx, q, hash)
if err != nil {
return fmt.Errorf("auth: pg session Revoke: %w", err)
}
if tag.RowsAffected() == 1 {
return nil
}
var exists bool
if err := s.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM session_tokens WHERE token_hash = $1)`, hash,
).Scan(&exists); err != nil {
return fmt.Errorf("auth: pg session Revoke exists check: %w", err)
}
if !exists {
return ErrInvalidToken
}
return nil // 已撤銷 → 冪等 no-op
}
// CleanupExpired 移除所有已過 expires_at 的 token回傳移除數量。
//
// expires_at IS NULL永不過期不會被刪。
func (s *PostgresSessionTokenStore) CleanupExpired(ctx context.Context, now time.Time) (int, error) {
const q = `DELETE FROM session_tokens
WHERE expires_at IS NOT NULL AND expires_at < $1`
tag, err := s.pool.Exec(ctx, q, now.UTC())
if err != nil {
return 0, fmt.Errorf("auth: pg session CleanupExpired: %w", err)
}
return int(tag.RowsAffected()), nil
}
// RevokeByDeviceTx 撤銷某 device 名下所有「尚未撤銷」的 session tokencascade 撤銷,塊 5.2)。
//
// 在傳入的 Querierpool 或 tx上跑 `UPDATE ... SET revoked_at = now() WHERE device_id = $1
// AND revoked_at IS NULL`database.md §6。回傳實際撤銷的列數觀測用無對象回 0、不報錯
//
// session_tokens.device_id 為 NOT NULL每個 session token 必綁 device故同一 device 的所有
// 未撤銷 session token 都會被撤——這正是「刪 device → 該 device 不能再被任何長效 token 觸達」的目的。
func (s *PostgresSessionTokenStore) RevokeByDeviceTx(ctx context.Context, q db.Querier, deviceID string) (int, error) {
if deviceID == "" {
return 0, nil
}
const sql = `UPDATE session_tokens
SET revoked_at = now()
WHERE device_id = $1 AND revoked_at IS NULL`
tag, err := q.Exec(ctx, sql, deviceID)
if err != nil {
return 0, fmt.Errorf("auth: pg session RevokeByDevice: %w", err)
}
return int(tag.RowsAffected()), nil
}
// scanSessionToken 從一列掃出 *SessionToken。欄位順序必須與 sessionColumns 對齊。
//
// parent_token_hash nullable → 以 *string 接、NULL 掃成空字串(對齊 in-memory zero value
// 時間欄位正規化為 UTC。Plaintext 留空DB 不存)。
func scanSessionToken(row rowScanner) (*SessionToken, error) {
var (
t SessionToken
parentHash *string
)
err := row.Scan(
&t.TokenHash,
&t.UserID,
&t.DeviceID,
&parentHash,
&t.CreatedAt,
&t.ExpiresAt,
&t.RevokedAt,
)
if err != nil {
return nil, err
}
if parentHash != nil {
t.ParentTokenHash = *parentHash
}
t.CreatedAt = t.CreatedAt.UTC()
t.ExpiresAt = utcPtr(t.ExpiresAt)
t.RevokedAt = utcPtr(t.RevokedAt)
return &t, nil
}