jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:

- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
  (tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
  WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
  - internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
  - internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
    防 session fixation, OWASP ASVS V3.2.1)
  - 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
  - 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
  - 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
    ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
  - OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
    (AuthStyleInParams 強制 token endpoint 不送 client_secret)
  - 預留 ServiceClient* 欄位給未來 client_credentials grant
  - 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
    (Audit C1:multi-tenant 隔離破口)
  - Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
  - 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:21:20 +08:00

517 lines
18 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.

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 的所有 tokenB5
// - DELETE /api/pairing/tokens/:token → 撤銷指定 tokenB5
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 取出 userIDOIDC 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 — 雛形必定非 nilpairingTokenTTL > 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
// 不帶 UserIDstrict 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 {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"list pairing tokens failed: "+err.Error(), nil)
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 parampath 可被日誌記錄 — 雛形容忍;
// 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
}
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"revoke token failed: "+err.Error(), nil)
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/exchangepublic — 不走 AuthMiddleware
// ==========================================================================
//
// 行為對齊 visiona-agent-tdd.md §4.3 + security.md §1.2
// 1. agent 送 Pairing Token 過來
// 2. 雲端驗證(存在 / 未過期 / 未使用 / 未撤銷)
// 3. 產生 Session TokenvAs_ + 64 hex90 天 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
}
// Generate Session Token
plaintext, sessionInfo, err := deps.SessionTokenStore.Create(
ctx,
info.UserID,
info.DeviceID, // Pairing Token 雛形還沒綁 device_id為空沒關係
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
}
// 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 沿用 info.DeviceID可能為空。雛形 MarkUsed 對空字串
// 是安全的(它只是覆寫欄位)。
if err := deps.PairingStore.MarkUsed(ctx, req.PairingToken, info.DeviceID); err != nil {
// 嘗試 revoke 剛產生的 session tokenrevoke 自身失敗不再 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", info.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.EmailOIDC 已帶)。
account := info.UserID + "@visionA.local"
// 故意只 log token prefix避免完整 session token 進日誌
logOrDefault(deps.Logger).Info("pairing exchange: success",
"user_id", info.UserID,
"device_id", info.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"
}
}