// 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 }