DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。 OIDC / pairing FK 修復(接 DB 上線必要): - 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback 驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID) - pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token; 自建 device 空 serial 寫 NULL(避免撞 partial unique) - device.SaveTx / session.CreateTx 新增 tx-aware 版本 B4 model metadata: - 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model: converter_client → flow → adapter → model.Model → PG → ModelResponse DTO - input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性 - 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md nginx healthz(部署層): - 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB - 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因) storage error 統一映射(不洩漏 storage 後端細節)。 測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、 internal/db(redis)、relay/session 弱處,含 testcontainers integration。 DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
551 lines
20 KiB
Go
551 lines
20 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"visiona-backend/internal/auth"
|
||
"visiona-backend/internal/session"
|
||
)
|
||
|
||
// pairingTokenTTL 是新發 pairing token 的存活時間。
|
||
//
|
||
// 對齊 security.md §1.3 的「短期一次性 token」設計:15 分鐘足夠完成
|
||
// 「使用者啟動 local-tool → 點配對按鈕 → token 進來」的流程。
|
||
const pairingTokenTTL = 15 * time.Minute
|
||
|
||
// registerPairingRoutes 註冊 /api/pairing/* 的 routes。
|
||
//
|
||
// MVP 全集合(B4 + B5):
|
||
// - POST /api/pairing/token → 建立 + 回傳 pairing token
|
||
// - GET /api/pairing/status → 回傳當前 user 的 tunnel 連線狀態
|
||
// - GET /api/pairing/tokens → 列當前 user 的所有 token(B5)
|
||
// - DELETE /api/pairing/tokens/:token → 撤銷指定 token(B5)
|
||
func registerPairingRoutes(g *gin.RouterGroup, deps Deps) {
|
||
g.POST("/pairing/token", pairingCreateTokenHandler(deps))
|
||
g.GET("/pairing/status", pairingStatusHandler(deps))
|
||
g.GET("/pairing/tokens", pairingListTokensHandler(deps))
|
||
g.DELETE("/pairing/tokens/:token", pairingRevokeTokenHandler(deps))
|
||
}
|
||
|
||
// PairingTokenResponse 是 POST /api/pairing/token 的 data payload。
|
||
//
|
||
// 對齊 api-spec.md §2:
|
||
//
|
||
// { "token": "vAc_...", "expires_at": "..." }
|
||
type PairingTokenResponse struct {
|
||
Token string `json:"token"`
|
||
ExpiresAt time.Time `json:"expires_at"`
|
||
}
|
||
|
||
// pairingCreateTokenHandler 建立一個新的 pairing token。
|
||
//
|
||
// 流程:
|
||
// 1. 從 UserContext 取出 userID(OIDC sub,由 AuthMiddleware 注入)
|
||
// 2. PairingStore.Create 產生一個合法格式的 vAc_ token
|
||
// 3. 回傳 token plaintext 給前端「只此一次」顯示
|
||
//
|
||
// 失敗回應:
|
||
// - PairingStore 未注入 → 501 NOT_IMPLEMENTED
|
||
// - Create 內部錯誤 → 500 INTERNAL_ERROR
|
||
//
|
||
// 安全提醒:plaintext 只回給合法登入的使用者,不寫進 server log。
|
||
func pairingCreateTokenHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if deps.PairingStore == nil {
|
||
WriteNotImplemented(c, "pairing store not configured")
|
||
return
|
||
}
|
||
|
||
// Phase 0.7 security fix M1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
// 移除 inline demo-user fallback:強制要求 AuthMiddleware 已注入合法 UserContext。
|
||
uc, ok := UserContextFrom(c)
|
||
if !ok || uc.UserID == "" {
|
||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||
"missing user context (auth middleware misconfigured?)", nil)
|
||
return
|
||
}
|
||
userID := uc.UserID
|
||
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
plaintext, info, err := deps.PairingStore.Create(ctx, userID, pairingTokenTTL)
|
||
if err != nil {
|
||
logOrDefault(deps.Logger).Error("pairing: create token failed",
|
||
"error", err,
|
||
"user_id", userID,
|
||
"request_id", RequestIDFrom(c))
|
||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||
"failed to create pairing token", nil)
|
||
return
|
||
}
|
||
|
||
// 取 ExpiresAt — 雛形必定非 nil(pairingTokenTTL > 0),保險檢查
|
||
var expires time.Time
|
||
if info != nil && info.ExpiresAt != nil {
|
||
expires = *info.ExpiresAt
|
||
}
|
||
|
||
// 故意不 log plaintext(只 log token prefix 與 expires)
|
||
logOrDefault(deps.Logger).Info("pairing: token created",
|
||
"user_id", userID,
|
||
"token_prefix", tokenPrefix(plaintext),
|
||
"expires_at", expires,
|
||
"request_id", RequestIDFrom(c))
|
||
|
||
WriteSuccess(c, http.StatusOK, PairingTokenResponse{
|
||
Token: plaintext,
|
||
ExpiresAt: expires,
|
||
})
|
||
}
|
||
}
|
||
|
||
// PairingStatusResponse 是 GET /api/pairing/status 的 data payload。
|
||
//
|
||
// 對齊 api-spec.md §2:
|
||
//
|
||
// {
|
||
// "connected": true,
|
||
// "connected_at": "...",
|
||
// "last_seen_at": "...",
|
||
// "device_id": "dev-xxx",
|
||
// "agent_version": "..."
|
||
// }
|
||
type PairingStatusResponse struct {
|
||
Connected bool `json:"connected"`
|
||
ConnectedAt *time.Time `json:"connected_at,omitempty"`
|
||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||
DeviceID string `json:"device_id,omitempty"`
|
||
AgentVersion string `json:"agent_version,omitempty"`
|
||
}
|
||
|
||
// pairingStatusHandler 回報當前 user 的 tunnel 連線狀態。
|
||
//
|
||
// 雛形實作:直接 List 所有 sessions(單 user 場景),找第一個。
|
||
// 多 user 階段(B5)會改成「按 user_id 過濾」並考慮多 device 場景。
|
||
func pairingStatusHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
resp := PairingStatusResponse{Connected: false}
|
||
|
||
if deps.SessionStore == nil {
|
||
WriteSuccess(c, http.StatusOK, resp)
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||
defer cancel()
|
||
|
||
summaries, err := deps.SessionStore.List(ctx)
|
||
if err != nil {
|
||
// 跟 system/health 一樣,list 失敗不致命,回 connected=false
|
||
logOrDefault(deps.Logger).Warn("pairing/status: list sessions failed",
|
||
"error", err,
|
||
"request_id", RequestIDFrom(c))
|
||
WriteSuccess(c, http.StatusOK, resp)
|
||
return
|
||
}
|
||
|
||
// Phase 0.7 security audit M2:寬鬆比對暫保留待人工介入修復。
|
||
// 詳細理由見 proxy.go pickActiveSessionToken 註解:relay 端 LocalHandle.Summary
|
||
// 不帶 UserID,strict equality 會讓所有 e2e proxy 鏈路全斷。
|
||
// 此處仍要求 UserContext 非空(C1 加固),但 s.UserID 暫接受空字串。
|
||
uc, ok := UserContextFrom(c)
|
||
var picked *session.Summary
|
||
if ok && uc != nil && uc.UserID != "" {
|
||
for _, s := range summaries {
|
||
// 寬鬆比對:暫接受 s.UserID == "" 直到 relay 端 backfill UserID。
|
||
if s.UserID == "" || s.UserID == uc.UserID {
|
||
picked = s
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if picked == nil {
|
||
WriteSuccess(c, http.StatusOK, resp)
|
||
return
|
||
}
|
||
|
||
resp.Connected = true
|
||
ca := picked.ConnectedAt
|
||
ls := picked.LastHeartbeat
|
||
resp.ConnectedAt = &ca
|
||
resp.LastSeenAt = &ls
|
||
resp.DeviceID = picked.DeviceID
|
||
// AgentVersion 雛形未從 tunnel 讀回;B5 會在 tunnel handshake 時收集
|
||
WriteSuccess(c, http.StatusOK, resp)
|
||
}
|
||
}
|
||
|
||
// tokenPrefix 截前 8 字元用於 log,避免完整 token 進日誌。
|
||
//
|
||
// 在 pairing.go 中重複實作(不複用 relay package 的同名函式)以避免跨層循環依賴;
|
||
// 行為與 relay.tokenPrefix 一致。
|
||
func tokenPrefix(t string) string {
|
||
if len(t) <= 8 {
|
||
return t
|
||
}
|
||
return t[:8]
|
||
}
|
||
|
||
// ==========================================================================
|
||
// B5 新增:List / Revoke tokens
|
||
// ==========================================================================
|
||
|
||
// PairingTokenListItem 是 GET /api/pairing/tokens 回應中的單筆 token。
|
||
//
|
||
// **注意**:不回 Plaintext — 那只能在建立時給一次。
|
||
type PairingTokenListItem struct {
|
||
TokenPrefix string `json:"token_prefix"` // 前 12 字元,例:`vAc_7f3c8e2a`
|
||
Kind string `json:"kind"`
|
||
DeviceID string `json:"device_id,omitempty"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||
UsedAt *time.Time `json:"used_at,omitempty"`
|
||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||
}
|
||
|
||
// pairingListTokensHandler 實作 GET /api/pairing/tokens。
|
||
//
|
||
// 回當前 user 的所有 token(含已使用 / 撤銷 / 過期),供 UI 顯示。
|
||
// **絕對不回 Plaintext** — 前端已在建立時保留那份 plaintext(只此一次)。
|
||
func pairingListTokensHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if deps.PairingStore == nil {
|
||
WriteNotImplemented(c, "pairing store not configured")
|
||
return
|
||
}
|
||
|
||
// Phase 0.7 security fix M1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
uc, ok := UserContextFrom(c)
|
||
if !ok || uc.UserID == "" {
|
||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||
"missing user context (auth middleware misconfigured?)", nil)
|
||
return
|
||
}
|
||
userID := uc.UserID
|
||
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||
defer cancel()
|
||
|
||
tokens, err := deps.PairingStore.List(ctx, userID)
|
||
if err != nil {
|
||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),收掉塊 3 M1 raw error 洩漏。
|
||
WriteDBError(c, deps.Logger, "list pairing tokens", err)
|
||
return
|
||
}
|
||
out := make([]PairingTokenListItem, 0, len(tokens))
|
||
for _, t := range tokens {
|
||
item := PairingTokenListItem{
|
||
TokenPrefix: tokenPrefix12(t.Plaintext),
|
||
Kind: string(t.Kind),
|
||
DeviceID: t.DeviceID,
|
||
CreatedAt: t.CreatedAt,
|
||
ExpiresAt: t.ExpiresAt,
|
||
UsedAt: t.UsedAt,
|
||
RevokedAt: t.RevokedAt,
|
||
}
|
||
out = append(out, item)
|
||
}
|
||
WriteSuccess(c, http.StatusOK, out)
|
||
}
|
||
}
|
||
|
||
// pairingRevokeTokenHandler 實作 DELETE /api/pairing/tokens/:token。
|
||
//
|
||
// 雛形:直接收 plaintext token 作為 path param(path 可被日誌記錄 — 雛形容忍;
|
||
// Phase 1 會改為回「token id」而非 plaintext,路徑不洩漏原文)。
|
||
//
|
||
// Revoke 成功回 204 No Content;不存在回 404 + NOT_FOUND。
|
||
func pairingRevokeTokenHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if deps.PairingStore == nil {
|
||
WriteNotImplemented(c, "pairing store not configured")
|
||
return
|
||
}
|
||
token := c.Param("token")
|
||
if token == "" {
|
||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||
"token param required", nil)
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||
defer cancel()
|
||
|
||
if err := deps.PairingStore.Revoke(ctx, token); err != nil {
|
||
if errors.Is(err, auth.ErrInvalidToken) {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "token not found", nil)
|
||
return
|
||
}
|
||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),收掉塊 3 M1 raw error 洩漏。
|
||
WriteDBError(c, deps.Logger, "revoke pairing token", err)
|
||
return
|
||
}
|
||
|
||
logOrDefault(deps.Logger).Info("pairing: token revoked",
|
||
"token_prefix", tokenPrefix(token),
|
||
"request_id", RequestIDFrom(c))
|
||
|
||
c.Status(http.StatusNoContent)
|
||
}
|
||
}
|
||
|
||
// tokenPrefix12 截前 12 字元(`vAc_` + 8 hex),用於 list 顯示。
|
||
func tokenPrefix12(t string) string {
|
||
if len(t) <= 12 {
|
||
return t
|
||
}
|
||
return t[:12]
|
||
}
|
||
|
||
// ==========================================================================
|
||
// AB11 新增:POST /api/pairing/exchange(public — 不走 AuthMiddleware)
|
||
// ==========================================================================
|
||
//
|
||
// 行為對齊 visiona-agent-tdd.md §4.3 + security.md §1.2:
|
||
// 1. agent 送 Pairing Token 過來
|
||
// 2. 雲端驗證(存在 / 未過期 / 未使用 / 未撤銷)
|
||
// 3. 產生 Session Token(vAs_ + 64 hex,90 天 TTL)
|
||
// 4. 把 Pairing Token 標為 used(一次性,無法再交換)
|
||
// 5. 回 { session_token, account, relay_url, expires_at }
|
||
//
|
||
// 雛形取捨:
|
||
// - remote-proxy 端目前只做 token 格式驗證(relay/server.go isAcceptableToken),
|
||
// **不會**實際查 SessionTokenStore。這是對齊 TDD「選項 A」的雛形設計;
|
||
// Phase 1 要新增 remote-proxy → api-server 的 `/internal/session-token/:token` 驗證。
|
||
// - Rate limit / token rotation / 真實 DB 都留給 Phase 1。
|
||
|
||
// defaultRelayPublicURL 是 relay_url 的雛形 placeholder,當 Deps.RelayPublicURL
|
||
// 未設定時用此值(讓 agent 至少能收到一個格式正確的 URL,實機請透過
|
||
// VISIONA_RELAY_PUBLIC_URL 覆寫)。
|
||
const defaultRelayPublicURL = "wss://relay.visionA.cloud"
|
||
|
||
// registerPairingPublicRoutes 註冊**不需要 auth**的 pairing endpoints。
|
||
//
|
||
// 目前只有 /api/pairing/exchange — agent 拿 Pairing Token 換 Session Token 時
|
||
// 本身還沒有登入身份,故不能套 AuthMiddleware。
|
||
// 呼叫方:NewRouter() 在 engine 層級直接註冊(不加 apiGroup)。
|
||
func registerPairingPublicRoutes(r gin.IRouter, deps Deps) {
|
||
r.POST("/api/pairing/exchange", pairingExchangeHandler(deps))
|
||
}
|
||
|
||
// PairingExchangeRequest 是 POST /api/pairing/exchange 的 request body。
|
||
type PairingExchangeRequest struct {
|
||
PairingToken string `json:"pairing_token" binding:"required"`
|
||
}
|
||
|
||
// PairingExchangeResponse 是 POST /api/pairing/exchange 成功時的 data payload。
|
||
//
|
||
// 欄位對齊 visiona-agent-tdd.md §7.1:
|
||
//
|
||
// {
|
||
// "session_token": "vAs_...",
|
||
// "account": "demo@visionA.local",
|
||
// "relay_url": "wss://relay.visionA.cloud",
|
||
// "expires_at": "2026-07-21T00:00:00Z"
|
||
// }
|
||
type PairingExchangeResponse struct {
|
||
SessionToken string `json:"session_token"`
|
||
Account string `json:"account"`
|
||
RelayURL string `json:"relay_url"`
|
||
ExpiresAt time.Time `json:"expires_at"`
|
||
}
|
||
|
||
// Pairing exchange 專用錯誤碼(對齊 TDD §7.1 四種 case)。
|
||
//
|
||
// 注意:這些 code 走 ErrorBody.Error.Code 欄位,不是 envelope top-level code。
|
||
// 刻意與 security.md §1.2 的語意一致;前端 agent 可以 switch 對應 UI 文案。
|
||
const (
|
||
ErrCodeInvalidPairingToken = "INVALID_PAIRING_TOKEN"
|
||
ErrCodePairingTokenExpired = "PAIRING_TOKEN_EXPIRED"
|
||
ErrCodePairingTokenUsed = "PAIRING_TOKEN_USED"
|
||
ErrCodePairingTokenRevoked = "PAIRING_TOKEN_REVOKED"
|
||
)
|
||
|
||
// pairingExchangeHandler 實作 Pairing → Session Token 交換。
|
||
//
|
||
// 失敗回應:
|
||
// - 400 VALIDATION_FAILED — body 缺 pairing_token
|
||
// - 401 INVALID_PAIRING_TOKEN — 格式錯 / 不存在
|
||
// - 401 PAIRING_TOKEN_EXPIRED — 過期
|
||
// - 401 PAIRING_TOKEN_USED — 已交換過
|
||
// - 401 PAIRING_TOKEN_REVOKED — 已撤銷
|
||
// - 500 INTERNAL_ERROR — Session Token 產生失敗
|
||
// - 501 NOT_IMPLEMENTED — store 未注入
|
||
//
|
||
// 安全提醒:回應內**絕對不包含** Pairing Token 原文;log 只印 prefix。
|
||
func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
// 雛形:store 任一缺失 → 501(避免 nil pointer)
|
||
if deps.PairingStore == nil || deps.SessionTokenStore == nil {
|
||
WriteNotImplemented(c, "pairing exchange store not configured")
|
||
return
|
||
}
|
||
|
||
// Parse body
|
||
var req PairingExchangeRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||
"pairing_token is required", nil)
|
||
return
|
||
}
|
||
|
||
// 格式驗證 — 不合格直接 401 INVALID_PAIRING_TOKEN(避免把格式錯誤漏進 store)
|
||
if !auth.IsValidPairingToken(req.PairingToken) {
|
||
logOrDefault(deps.Logger).Warn("pairing exchange: invalid token format",
|
||
"token_prefix", tokenPrefix(req.PairingToken),
|
||
"request_id", RequestIDFrom(c))
|
||
WriteError(c, http.StatusUnauthorized, ErrCodeInvalidPairingToken,
|
||
"pairing token format invalid", nil)
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
// Validate — store 會判斷存在 / 未過期 / 未使用 / 未撤銷
|
||
info, err := deps.PairingStore.Validate(ctx, req.PairingToken)
|
||
if err != nil {
|
||
code, msg := mapPairingExchangeError(err)
|
||
logOrDefault(deps.Logger).Warn("pairing exchange: validate failed",
|
||
"code", code,
|
||
"token_prefix", tokenPrefix(req.PairingToken),
|
||
"request_id", RequestIDFrom(c))
|
||
WriteError(c, http.StatusUnauthorized, code, msg, nil)
|
||
return
|
||
}
|
||
|
||
// 自建 device + 建綁該 device 的 session token(DB-on FK 收尾,問題 #2)。
|
||
//
|
||
// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從不建 device。
|
||
// 改由 PairingExchanger 在 exchange 時自建一筆 device(owner = info.UserID,該 user 已透過
|
||
// OIDC callback provision 進 users 表),並建綁該 device 的 session token——Postgres 後端用
|
||
// 單一交易把兩步包成原子(建 device 失敗或建 token 失敗都整筆 rollback)。
|
||
//
|
||
// info.TokenHash 作為 parent_token_hash(稽核鏈:session token ← pairing token)。
|
||
//
|
||
// PairingExchanger 為 nil(DB-off 雛形最小骨架,未注入)→ fallback 到舊行為:直接用
|
||
// info.DeviceID(可能為空)建 session token,不自建 device。in-memory store 不檢查 FK,
|
||
// 空 deviceID 可接受;此分支只在 main.go 沒注入 exchanger 時走到。
|
||
var (
|
||
plaintext string
|
||
sessionInfo *auth.SessionToken
|
||
deviceID string
|
||
)
|
||
if deps.PairingExchanger != nil {
|
||
res, exErr := deps.PairingExchanger.Provision(ctx, info.UserID, info.TokenHash, auth.SessionTokenTTL)
|
||
if exErr != nil {
|
||
logOrDefault(deps.Logger).Error("pairing exchange: provision device+session failed",
|
||
"error", exErr,
|
||
"user_id", info.UserID,
|
||
"request_id", RequestIDFrom(c))
|
||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||
"failed to create session token", nil)
|
||
return
|
||
}
|
||
plaintext = res.SessionPlaintext
|
||
sessionInfo = res.SessionInfo
|
||
deviceID = res.DeviceID
|
||
} else {
|
||
var err error
|
||
plaintext, sessionInfo, err = deps.SessionTokenStore.Create(
|
||
ctx,
|
||
info.UserID,
|
||
info.DeviceID, // fallback:雛形未綁 device_id,為空(僅 in-memory 可接受)
|
||
info.TokenHash,
|
||
auth.SessionTokenTTL,
|
||
)
|
||
if err != nil {
|
||
logOrDefault(deps.Logger).Error("pairing exchange: create session token failed",
|
||
"error", err,
|
||
"request_id", RequestIDFrom(c))
|
||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||
"failed to create session token", nil)
|
||
return
|
||
}
|
||
deviceID = info.DeviceID
|
||
}
|
||
|
||
// Mark pairing token as used。
|
||
//
|
||
// Phase 0.7 security fix M3 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
// MarkUsed 失敗代表「pairing token 一次性」保證可能被破壞 — 同一個 token
|
||
// 可能再被 exchange 一次。改為 abort:撤銷剛產生的 session token、回 500,
|
||
// 而不是 silent log warn 繼續往前。
|
||
//
|
||
// 注意:deviceID 現在綁的是自建 device(DB-on)或 info.DeviceID(fallback)。
|
||
// MarkUsed 把 pairing token 也綁上同一個 device_id(稽核:哪台 device 用掉了這個 token)。
|
||
if err := deps.PairingStore.MarkUsed(ctx, req.PairingToken, deviceID); err != nil {
|
||
// 嘗試 revoke 剛產生的 session token;revoke 自身失敗不再 retry,只 log。
|
||
revokeErr := deps.SessionTokenStore.Revoke(ctx, plaintext)
|
||
logOrDefault(deps.Logger).Error("pairing exchange: mark used failed; aborted",
|
||
"error", err,
|
||
"revoke_err", revokeErr,
|
||
"token_prefix", tokenPrefix(req.PairingToken),
|
||
"session_token_prefix", tokenPrefix(plaintext),
|
||
"device_id", deviceID,
|
||
"request_id", RequestIDFrom(c))
|
||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||
"pairing token mark-used failed; aborted", nil)
|
||
return
|
||
}
|
||
|
||
// 組 response
|
||
relayURL := deps.RelayPublicURL
|
||
if relayURL == "" {
|
||
relayURL = defaultRelayPublicURL
|
||
}
|
||
|
||
var expires time.Time
|
||
if sessionInfo.ExpiresAt != nil {
|
||
expires = *sessionInfo.ExpiresAt
|
||
} else {
|
||
expires = time.Now().UTC().Add(auth.SessionTokenTTL)
|
||
}
|
||
|
||
// 雛形 account — OIDC sub 通常是 UUID 不適合給人看,這裡用 userID + 固定 suffix
|
||
// 當 placeholder。Phase 1 接 DB 後可改回 session.Email(OIDC 已帶)。
|
||
account := info.UserID + "@visionA.local"
|
||
|
||
// 故意只 log token prefix,避免完整 session token 進日誌
|
||
logOrDefault(deps.Logger).Info("pairing exchange: success",
|
||
"user_id", info.UserID,
|
||
"device_id", deviceID,
|
||
"session_token_prefix", tokenPrefix(plaintext),
|
||
"pairing_token_prefix", tokenPrefix(req.PairingToken),
|
||
"request_id", RequestIDFrom(c))
|
||
|
||
WriteSuccess(c, http.StatusOK, PairingExchangeResponse{
|
||
SessionToken: plaintext,
|
||
Account: account,
|
||
RelayURL: relayURL,
|
||
ExpiresAt: expires,
|
||
})
|
||
}
|
||
}
|
||
|
||
// mapPairingExchangeError 把 PairingStore.Validate 回傳的 sentinel error 轉成
|
||
// 對應的 pairing exchange error code + 對使用者可見的訊息。
|
||
//
|
||
// 未匹配時 fallback 到 INVALID_PAIRING_TOKEN(避免洩漏內部錯誤細節)。
|
||
func mapPairingExchangeError(err error) (code, message string) {
|
||
switch {
|
||
case errors.Is(err, auth.ErrTokenExpired):
|
||
return ErrCodePairingTokenExpired, "pairing token expired"
|
||
case errors.Is(err, auth.ErrTokenUsed):
|
||
return ErrCodePairingTokenUsed, "pairing token already used"
|
||
case errors.Is(err, auth.ErrTokenRevoked):
|
||
return ErrCodePairingTokenRevoked, "pairing token revoked"
|
||
case errors.Is(err, auth.ErrInvalidToken):
|
||
return ErrCodeInvalidPairingToken, "pairing token invalid"
|
||
default:
|
||
return ErrCodeInvalidPairingToken, "pairing token invalid"
|
||
}
|
||
}
|