把 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>
324 lines
11 KiB
Go
324 lines
11 KiB
Go
// PostgresPairingStore 是 PairingStore 的 PostgreSQL 持久層實作(DB 接入塊 3)。
|
||
//
|
||
// 與 InMemoryPairingStore 實作相同的 PairingStore interface,讓 main.go 在 dbPool != nil
|
||
// 時無痛切換、handler 與呼叫端(internal/api/pairing.go)一行都不需改。
|
||
//
|
||
// 對齊:
|
||
// - database.md §2.4(PairingToken struct)、§4(pairing_tokens 表 schema)
|
||
// - migrations/0003_create_token_tables.up.sql(pairing_tokens 表)
|
||
//
|
||
// ── 關鍵改動:plaintext → token_hash 當 PK(database.md 結尾提醒、塊 3 子任務 3.3)──
|
||
//
|
||
// in-memory 版以 plaintext token 當 map key;Postgres 版改以 token_hash(HashToken(plaintext))
|
||
// 當 PK,DB 永不存明文 token(security.md §1.3)。
|
||
//
|
||
// 所有「以 plaintext 查詢」的方法(Validate / MarkUsed / Revoke)一律先 HashToken(plaintext)
|
||
// 再以 hash 比對。呼叫端統一傳 plaintext 進來(已 grep 確認:internal/api/pairing.go 的
|
||
// Validate / MarkUsed / Revoke 全部傳 plaintext 的 vAc_ token),store 內部統一 hash,
|
||
// 不會有「漏 hash 某個呼叫端」的問題。
|
||
//
|
||
// 語意對齊 in-memory(見 inmemory_pairing_store.go):
|
||
// - Validate 的狀態優先序:revoked → used → expired(與 in-memory 完全一致)。
|
||
// - MarkUsed 一次性 + 冪等:未使用 → 寫 used_at + device_id;已使用 → no-op 回 nil(不覆寫 device_id);
|
||
// 不存在 → ErrInvalidToken。DB 層用 `WHERE used_at IS NULL` 達成兩併發只一筆實際標記。
|
||
// - Revoke 冪等:未撤銷 → 寫 revoked_at;已撤銷 → no-op nil;不存在 → ErrInvalidToken。
|
||
// - CleanupExpired:DELETE 所有 expires_at < now 的列,回刪除數。
|
||
package auth
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"visiona-backend/internal/db"
|
||
)
|
||
|
||
// PostgresPairingStore 是 pairing token 的 PostgreSQL 持久層實作。
|
||
type PostgresPairingStore struct {
|
||
pool *pgxpool.Pool
|
||
}
|
||
|
||
// NewPostgresPairingStore 建立一個以 pgxpool 為後端的 PairingStore。
|
||
//
|
||
// pool 由 internal/db 的 NewPool 建立並注入;本套件不持有建池 / 關閉責任。
|
||
func NewPostgresPairingStore(pool *pgxpool.Pool) *PostgresPairingStore {
|
||
return &PostgresPairingStore{pool: pool}
|
||
}
|
||
|
||
// 編譯時檢查:確保 PostgresPairingStore 實作 PairingStore。
|
||
var _ PairingStore = (*PostgresPairingStore)(nil)
|
||
|
||
// pairingColumns 是 SELECT 共用欄位清單(順序必須與 scanPairingToken 對齊)。
|
||
//
|
||
// 注意:DB 不存 plaintext,故掃出的 PairingToken.Plaintext 永遠為空字串(符合預期,
|
||
// 呼叫端在 Validate 後只用 UserID / DeviceID / TokenHash,不依賴 Plaintext)。
|
||
const pairingColumns = `token_hash, user_id, device_id, kind,
|
||
created_at, expires_at, used_at, revoked_at`
|
||
|
||
// Create 產生並保存一個新 pairing token。
|
||
//
|
||
// ttl <= 0 時 ExpiresAt 保持 NULL(永不過期;測試用)。
|
||
// 回傳的 info.Plaintext 保留原文供 caller 一次性使用(DB 不存)。
|
||
func (s *PostgresPairingStore) 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,
|
||
}
|
||
var expiresAt any // nil → DB NULL
|
||
if ttl > 0 {
|
||
expires := now.Add(ttl)
|
||
info.ExpiresAt = &expires
|
||
expiresAt = expires
|
||
}
|
||
|
||
const q = `INSERT INTO pairing_tokens
|
||
(token_hash, user_id, kind, created_at, expires_at)
|
||
VALUES ($1, $2, $3, $4, $5)`
|
||
if _, err := s.pool.Exec(ctx, q,
|
||
info.TokenHash, info.UserID, string(info.Kind), info.CreatedAt, expiresAt,
|
||
); err != nil {
|
||
return "", nil, fmt.Errorf("auth: pg pairing Create: %w", err)
|
||
}
|
||
return plaintext, info, nil
|
||
}
|
||
|
||
// Validate 檢查 token 是否存在且可用(未撤銷、未使用、未過期)。
|
||
//
|
||
// 接收 plaintext,內部 HashToken 後查詢。狀態優先序對齊 in-memory:
|
||
// revoked → used → expired。不存在回 ErrInvalidToken。
|
||
func (s *PostgresPairingStore) Validate(ctx context.Context, token string) (*PairingToken, error) {
|
||
hash := HashToken(token)
|
||
|
||
const q = `SELECT ` + pairingColumns + `
|
||
FROM pairing_tokens WHERE token_hash = $1`
|
||
row := s.pool.QueryRow(ctx, q, hash)
|
||
info, err := scanPairingToken(row)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrInvalidToken
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("auth: pg pairing Validate: %w", err)
|
||
}
|
||
|
||
if info.IsRevoked() {
|
||
return nil, ErrTokenRevoked
|
||
}
|
||
if info.IsUsed() {
|
||
return nil, ErrTokenUsed
|
||
}
|
||
if info.IsExpired(time.Now().UTC()) {
|
||
return nil, ErrTokenExpired
|
||
}
|
||
return info, nil
|
||
}
|
||
|
||
// MarkUsed 標記一次性 token 為已使用並綁定 deviceID。
|
||
//
|
||
// 一次性 + 冪等語意(DB 層):
|
||
// - 以 `WHERE token_hash = $ AND used_at IS NULL` UPDATE:兩併發只一筆 RowsAffected = 1
|
||
// (DB 行鎖保證),是「真正標記成功」的那一個。
|
||
// - RowsAffected = 0 時需區分「已使用(冪等 no-op,回 nil、不覆寫 device_id)」與
|
||
// 「不存在(ErrInvalidToken)」→ 再查一次存在性。
|
||
//
|
||
// deviceID 可為空字串(雛形 pairing 尚未綁 device 時),對齊 in-memory。
|
||
func (s *PostgresPairingStore) MarkUsed(ctx context.Context, token, deviceID string) error {
|
||
hash := HashToken(token)
|
||
|
||
// device_id 為 UUID 欄位且 nullable:空字串無法寫進 UUID 欄位,轉成 NULL。
|
||
var deviceArg any
|
||
if deviceID != "" {
|
||
deviceArg = deviceID
|
||
}
|
||
|
||
const q = `UPDATE pairing_tokens
|
||
SET used_at = now(), device_id = $2
|
||
WHERE token_hash = $1 AND used_at IS NULL`
|
||
tag, err := s.pool.Exec(ctx, q, hash, deviceArg)
|
||
if err != nil {
|
||
return fmt.Errorf("auth: pg pairing MarkUsed: %w", err)
|
||
}
|
||
if tag.RowsAffected() == 1 {
|
||
return nil // 本呼叫為實際標記成功者
|
||
}
|
||
|
||
// RowsAffected == 0:可能已使用(冪等)或不存在。查存在性以區分。
|
||
exists, err := s.tokenExists(ctx, hash)
|
||
if err != nil {
|
||
return fmt.Errorf("auth: pg pairing MarkUsed exists check: %w", err)
|
||
}
|
||
if !exists {
|
||
return ErrInvalidToken
|
||
}
|
||
return nil // 已使用 → 冪等 no-op
|
||
}
|
||
|
||
// Revoke 撤銷一個 token(之後 Validate 回 ErrTokenRevoked)。
|
||
//
|
||
// 冪等:已撤銷 → no-op nil;不存在 → ErrInvalidToken。
|
||
func (s *PostgresPairingStore) Revoke(ctx context.Context, token string) error {
|
||
hash := HashToken(token)
|
||
|
||
const q = `UPDATE pairing_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 pairing Revoke: %w", err)
|
||
}
|
||
if tag.RowsAffected() == 1 {
|
||
return nil
|
||
}
|
||
|
||
exists, err := s.tokenExists(ctx, hash)
|
||
if err != nil {
|
||
return fmt.Errorf("auth: pg pairing Revoke exists check: %w", err)
|
||
}
|
||
if !exists {
|
||
return ErrInvalidToken
|
||
}
|
||
return nil // 已撤銷 → 冪等 no-op
|
||
}
|
||
|
||
// List 回傳指定 user 的所有 pairing token(含已使用 / 撤銷),created_at DESC。
|
||
//
|
||
// 注意:回傳的 token Plaintext 為空(DB 不存明文);caller 不應依賴 Plaintext。
|
||
func (s *PostgresPairingStore) List(ctx context.Context, userID string) ([]*PairingToken, error) {
|
||
const q = `SELECT ` + pairingColumns + `
|
||
FROM pairing_tokens WHERE user_id = $1
|
||
ORDER BY created_at DESC`
|
||
rows, err := s.pool.Query(ctx, q, userID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("auth: pg pairing List query: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := make([]*PairingToken, 0)
|
||
for rows.Next() {
|
||
info, scanErr := scanPairingToken(rows)
|
||
if scanErr != nil {
|
||
return nil, fmt.Errorf("auth: pg pairing List scan: %w", scanErr)
|
||
}
|
||
out = append(out, info)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("auth: pg pairing List rows: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// CleanupExpired 移除所有已過 expires_at 的 token;回傳移除數量。
|
||
//
|
||
// expires_at IS NULL(永不過期)不會被刪。
|
||
func (s *PostgresPairingStore) CleanupExpired(ctx context.Context, now time.Time) (int, error) {
|
||
const q = `DELETE FROM pairing_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 pairing CleanupExpired: %w", err)
|
||
}
|
||
return int(tag.RowsAffected()), nil
|
||
}
|
||
|
||
// RevokeByDeviceTx 撤銷某 device 名下所有「尚未撤銷」的 pairing token(cascade 撤銷,塊 5.2)。
|
||
//
|
||
// 在傳入的 Querier(pool 或 tx)上跑 `UPDATE ... SET revoked_at = now() WHERE device_id = $1
|
||
// AND revoked_at IS NULL`(database.md §6)。回傳實際撤銷的列數(觀測用,無撤銷對象回 0、不報錯)。
|
||
//
|
||
// 對齊 database.md §6:刪 device 時對 pairing_tokens + session_tokens 各跑一次此類 UPDATE,
|
||
// 須在同一 tx 內。本方法只負責 pairing 表那一半;device 軟刪與 session 撤銷由 cascade 協調者
|
||
// 在同一個 db.WithTx 內串起。
|
||
//
|
||
// 注意:pairing token 的 device_id 只有在 MarkUsed 綁定後才有值;未綁定的 token(device_id IS NULL)
|
||
// 不屬於任何 device,自然不會被本查詢撤銷,符合語意。
|
||
func (s *PostgresPairingStore) RevokeByDeviceTx(ctx context.Context, q db.Querier, deviceID string) (int, error) {
|
||
if deviceID == "" {
|
||
return 0, nil // 無 device 對象(對齊 in-memory:空 deviceID 不撤任何 token)
|
||
}
|
||
const sql = `UPDATE pairing_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 pairing RevokeByDevice: %w", err)
|
||
}
|
||
return int(tag.RowsAffected()), nil
|
||
}
|
||
|
||
// tokenExists 查指定 hash 的 pairing token 是否存在(不論狀態)。
|
||
func (s *PostgresPairingStore) tokenExists(ctx context.Context, hash string) (bool, error) {
|
||
var exists bool
|
||
err := s.pool.QueryRow(ctx,
|
||
`SELECT EXISTS(SELECT 1 FROM pairing_tokens WHERE token_hash = $1)`, hash,
|
||
).Scan(&exists)
|
||
return exists, err
|
||
}
|
||
|
||
// scanPairingToken 從一列掃出 *PairingToken。欄位順序必須與 pairingColumns 對齊。
|
||
//
|
||
// device_id 為 nullable UUID(MarkUsed 前為 NULL)→ 以 *string 接、NULL 掃成空字串
|
||
// (對齊 in-memory zero value)。時間欄位正規化為 UTC。Plaintext 留空(DB 不存)。
|
||
func scanPairingToken(row rowScanner) (*PairingToken, error) {
|
||
var (
|
||
t PairingToken
|
||
deviceID *string
|
||
kind string
|
||
)
|
||
err := row.Scan(
|
||
&t.TokenHash,
|
||
&t.UserID,
|
||
&deviceID,
|
||
&kind,
|
||
&t.CreatedAt,
|
||
&t.ExpiresAt,
|
||
&t.UsedAt,
|
||
&t.RevokedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
t.Kind = TokenKind(kind)
|
||
if deviceID != nil {
|
||
t.DeviceID = *deviceID
|
||
}
|
||
|
||
t.CreatedAt = t.CreatedAt.UTC()
|
||
t.ExpiresAt = utcPtr(t.ExpiresAt)
|
||
t.UsedAt = utcPtr(t.UsedAt)
|
||
t.RevokedAt = utcPtr(t.RevokedAt)
|
||
return &t, nil
|
||
}
|
||
|
||
// ==========================================================================
|
||
// shared scan helpers(pairing + session token 共用)
|
||
// ==========================================================================
|
||
|
||
// rowScanner 抽象 pgx.Row 與 pgx.Rows 的共同 Scan 介面,讓 scan helper 同時服務單列查詢與 List。
|
||
type rowScanner interface {
|
||
Scan(dest ...any) error
|
||
}
|
||
|
||
// utcPtr 將 nullable 時間指標正規化為 UTC(nil 維持 nil)。
|
||
func utcPtr(p *time.Time) *time.Time {
|
||
if p == nil {
|
||
return nil
|
||
}
|
||
u := p.UTC()
|
||
return &u
|
||
}
|