jim800121chen fb7da5d180 chore(autoflow): migrate .autoflow/ 共享層文件至 docs/autoflow/
依 autoflow-agent workspace v2 設計把 PRD / 設計 / 架構 / 交付類
共享文件從個人層 .autoflow/(ignored)搬到 docs/autoflow/(進 git),
讓團隊可共享產品與架構文件,個人層只留 progress / review / testing 等
per-branch 筆記。

- 02-prd/        21 個檔(PRD、features、market-analysis 等)
- 03-design/     18 個檔(design-spec、wireframes、flows 等)
- 04-architecture/ 31 個檔(TDD、design-doc、ADR×14、API 規格等)
- 07-delivery/   3 個檔(project-summary、phase-0.6-handover、stage-deployment-setup)

合計 73 檔。原檔已從 .autoflow/ 移除(migration 工具執行 git mv,
但因 .autoflow/ 在 .gitignore 中、git 將此操作視為新增、無 rename history)。
2026-05-04 16:55:55 +08:00

22 KiB
Raw Permalink Blame History

Security — 安全考量與 Pairing Token 協定

本文件彙整所有安全相關細節。部分對應 Design Doc §6。


1. Pairing Token 協定

1.1 雛形版v0.1

最簡化單一 token

[開發者 setup]
  # 產生符合正式格式的雛形 tokenvAc_ + 32 hex
  export VISIONA_PAIRING_TOKEN="vAc_$(openssl rand -hex 16)"
  export VISIONA_STATIC_USER_ID=demo-user

[local agent]
  啟動時讀同一個 env或 config 檔 / CLI flag
  連 ws://proxy:3800/tunnel/connect?token=$VISIONA_PAIRING_TOKEN

[remote-proxy]
  PairingStore.Validate(token):
    if token == env.VISIONA_PAIRING_TOKEN:
      return {UserID: env.VISIONA_STATIC_USER_ID, TokenHash: sha256hex(token)}
    else:
      return ErrInvalidToken

雛形限制

  • 單 token 單 user永不過期無法 revoke
  • 不做 Stage 2 升級pairing 和 session 合併為同一個 token端點收到任何 vAc_* 格式就接受)
  • 只適合 devPhase 1 切入正式 DB-backed 前,必須先改為 Stage 1 + Stage 2 兩階段(見 §1.2、ADR-003

1.2 Phase 1DB-backed + 兩階段 Token

詳見 ADR-003。雛形 → Phase 1 流程定義如下:

Stage 1 — Pairing Token短期15 分鐘 TTL,一次性)

使用者於 Web 頁面點「Pair New Device」

POST /api/pairing/token
  Auth: bearer <user JWT>

Response 200:
  {
    "token": "vAc_a1b2c3d4e5f6...",            // 明文只此一次vAc_ + 32 hex共 36 字元)
    "expires_at": "2026-04-21T13:15:00Z"       // 15 分鐘後過期
  }

DB 寫入 pairing_tokens:
  token_hash = sha256(plain)
  user_id    = <from JWT>
  kind       = 'pairing'
  expires_at = now + 15min
  used_at    = NULL

使用者把 token 貼到 local agent複製貼上、QR code、CLI flag 皆可)。

Stage 2 — Session Token長期90 天 TTL,可撤銷)

local agent 首次用 Pairing Token 連 proxy遠端會換發 Session Token

WS /tunnel/connect?token=vAc_a1b2c3d4...

remote-proxy (經 PairingStore.Validate):
  info := SELECT * FROM pairing_tokens WHERE token_hash = sha256($1)
            AND kind = 'pairing'
            AND revoked_at IS NULL
            AND expires_at > now()
            AND used_at IS NULL

  IF info NOT FOUND → reject 401

  # 原子升級 (single transaction)
  BEGIN;
    device_id := create_device_if_needed(info.user_id, agent_serial)
    session_plain := "vAs_" + hex(crypto/rand(32))        # 64 hex chars
    INSERT INTO pairing_tokens
      (token_hash, user_id, device_id, kind, parent_token, expires_at, created_at)
    VALUES
      (sha256(session_plain), info.user_id, device_id,
       'session', info.token_hash, now() + 90 days, now());
    UPDATE pairing_tokens
      SET used_at = now(), device_id = $device_id
      WHERE token_hash = info.token_hash;
  COMMIT;

  # 在 WS upgrade response header 或首個 yamux control frame 回傳:
  #   { "session_token": "vAs_..." }
  accept tunnel

local agent 收到後持久化 Session Tokenconfig / keychain丟棄 Pairing Token(已作廢)。日後重連一律用 Session Tokenkind='session' 的 token 重連只需:

info := SELECT * FROM pairing_tokens WHERE token_hash = sha256($1)
          AND kind = 'session'
          AND revoked_at IS NULL
          AND expires_at > now()
IF info NOT FOUND → reject 401使用者需在 Web UI 重 Pair
ELSE → accept tunnel + UPDATE last_seen_at

撤銷:使用者在 Web UI「Devices → Revoke」→ UPDATE pairing_tokens SET revoked_at = now() WHERE token_hash = ?。下次連線驗證即被拒。

1.3 Token 格式2026-04-22 M-1 修訂:統一為 hex + 前綴)

vAc_[0-9a-f]{32}     # Admin-Credentialpairing共 36 字元
vAs_[0-9a-f]{64}     # Agent-Sessionsession共 68 字元
  • 字元集:小寫 hex + 底線前綴(不用 base64url避免 URL-safe 混淆 / 大小寫歧義)
  • 產生:crypto/rand.Readhex.EncodeToString
  • API 回傳純字串,無空格、無分隔符(前端 UI 顯示時可每 8 字元插空格提升可讀性;複製/貼上時前端需 .replace(/\s/g, '') 正規化)
  • 正則驗證:
    • Pairing^vAc_[0-9a-f]{32}$
    • Session^vAs_[0-9a-f]{64}$
  • 前綴讓 log / debug 一眼看出類型log 永遠只記前 8 字元前綴(例:vAc_a1b2c3d4...

1.4 Hand-shake 額外欄位Phase 1

WS upgrade 前local agent 可帶附加 header 宣告自己:

GET /tunnel/connect?token=vAc_a1b2c3d4... HTTP/1.1
X-Agent-Version: local-tool 1.2.3
X-Agent-OS: darwin/arm64
X-Agent-Serial: KL520-1234-ABCD

proxy 驗證 + 記錄到 pairing_tokens.device_id 綁定。


2. Auth使用者登入

2.0 介面雙層2026-04-22 新增 M-3

Auth 切分為兩層 interface對應不同生命週期 / 呼叫場景:

介面 層級 呼叫時機 方法
AuthService Middleware 層 每個 HTTP request 進來時middleware Authenticate(req) → UserContextAuthorize(ctx, resource, action)
AuthProvider Handler 層 使用者登入 / 註冊 / 登出等明確動作 RegisterLoginLogoutValidateTokenGetUser

雙層並存原因:

  • AuthService 著重「這個 request 代表哪位使用者」(狀態萃取),每個 request 都要跑
  • AuthProvider 著重「使用者的 Auth 生命週期管理」(帳號 CRUD + token 簽發),只在登入登出等動作發生

實作時通常一個底層 providerClerk / OIDC client會同時被 AuthProviderAuthService 包裝;但 interface 分開讓 handler 不需要知道 middleware 的細節。

// internal/auth/service.gomiddleware 層)
type AuthService interface {
    Authenticate(ctx context.Context, req *http.Request) (*UserContext, error)
    Authorize(ctx context.Context, userCtx *UserContext, resource, action string) error
}

// internal/auth/provider.gohandler 層2026-04-22 新增)
type AuthProvider interface {
    Register(ctx context.Context, req *RegisterRequest) (*User, error)
    Login(ctx context.Context, req *LoginRequest) (*LoginResult, error)
    Logout(ctx context.Context, token string) error
    ValidateToken(ctx context.Context, token string) (*UserContext, error)
    GetUser(ctx context.Context, userID string) (*User, error)
}

type LoginRequest  struct { Email, Password string }
type LoginResult   struct { User *User; AccessToken, RefreshToken string; ExpiresAt time.Time }
type RegisterRequest struct { Email, Password, Name string }

2.1 雛形

⚠️ 已被 OIDC 取代2026-04-26 / OB5

本節描述的 StaticAuthProvider / StaticAuthService 已從 codebase 移除。 Phase 0.6 起 visionA-backend 的唯一認證路徑是 OIDC接 Innovedus Member Center 詳見:

demo-user 仍可用 — Member Center 端 seed demo@visionA.local / demo123 帳號。

歷史紀錄(已過時)

  • /api/auth/login/api/auth/register 曾由 StaticAuthProvider 實作:
    • Login(email, password)任何帳密都通過,回 demo-user + 假的 access tokendemo-access-token
    • Register(...) — stubErrNotImplemented(前端顯示「即將推出」)
    • Logout(...) — stub清前端 store 即可)
    • ValidateToken("demo-access-token") → demo-user其他 token → ErrInvalidToken
    • GetUser("demo-user") → demo-user struct其他 → ErrNotFound
  • StaticAuthService.Authenticate 永遠回 demo-usermiddleware 層;與 provider 結果一致)
  • 前端 login 頁面可填任意帳密、點下去直接進入應用(體驗接近真實但零 DB

2.2 Phase 1 選項

方案
自建 email + password + JWT 完全掌控 要自己做 email 驗證、密碼重設、暴力破解防禦
Clerk / Auth0 / Supabase Auth 現成、支援社交登入 綁定 vendor成本隨用戶增加
OIDC 自建Keycloak / Ory Kratos 開源、標準 維運成本

建議Phase 1 初期用 Clerk免費額度充足支援 Google/GitHub SSO、Magic Link、MFA成長後視成本再評估遷移。

2.3 Session 機制

  • JWTsub=user_id, exp, roles)放 Authorization: Bearer ... header
  • Refresh token 存 HttpOnly cookie避免 XSS 拿到)
  • Logout清 cookie + JWT 黑名單Redis

2.4 Service-to-Service TokenPhase 0.8 啟用)

對齊 adr/adr-014-conversion-integration.mdconversion.md §5。

visionA-backend 從 Phase 0.8 起除了「user 端的 OIDC BFF flow」§2.0),還會以服務身份呼叫 Innovedus Member Center / FAA / converter — 用 OAuth 2.0 client_credentials grant。

2.4.1 流程

visionA-backend ──POST {issuer}/oauth/token──► Member Center
   grant_type=client_credentials
   client_id=<ServiceClientID>          ← 預埋於 OIDCConfig.ServiceClientID
   client_secret=<ServiceClientSecret>  ← 預埋於 OIDCConfig.ServiceClientSecret
   scope=converter:job.write converter:job.read files:download.read files:download.delegate

   ◄─────── { access_token, expires_in, scope }
   
   cacheexp - 15s 內可重用)

2.4.2 Cache 策略

  • 單一 token cache 涵蓋全部 4 個 scopeMC 端發單一 token 含全部)
  • cache 在 visionA-backend 進程記憶體(sync.RWMutex 保護)
  • exp - 15s 提前重取,避免下游使用時剛好過期
  • 併發保護double-checked locking第一次 cache miss 時其他 goroutine 等待)
  • 重啟即清空in-memory無持久化下次需要時重新取

2.4.3 失敗處理

場景 處理
4xxclient_id / scope 設定錯) fatallog + 5xx response 給上游 caller不重試
5xx / network 指數退避 max 2 次1s, 2s
Token cache 命中 → 但 MC 已 invalidaterare 下次 401 時 force refresh + 重打

2.4.4 Secret 管理

  • VISIONA_OIDC_SERVICE_CLIENT_SECRET 絕不可 commit 進 git.gitignore.env
  • prod 用 AWS Secrets Manager / k8s Secret 注入
  • log 永遠不印完整 token只印前 8 字元前綴(Bearer ey1234...
  • 若 secret 洩漏MC 端 rotate → 重新部署 visionA-backendin-memory cache 自然失效

2.4.5 Delegated Download Token

不同於上面的 service tokenFAA delegated download token 是「visionA backend 用自己的 service token 跟 MC 換來、再轉發給 browser 直連 FAA 用的短期 opaque token」

visionA backend ──POST /file-access/download-tokens (Bearer service-token, scope=files:download.delegate)──► MC
   body: { tenant_id, user_id, object_key, method:"GET", expires_in_seconds:300 }
   
   ◄─────── { token, url, expires_at }

visionA backend ──回 frontend──► { download_url, expires_at }

Browser ──GET /files/{key}?access_token=<delegated>──► FAA
                                                       │
                                                       └──► MC validate token

安全特性:

  • TTL 短(預設 5 分鐘 / 範圍 60-900sVISIONA_FAA_DELEGATED_TTL_SECONDS 控制)
  • 綁定 method=GET + 特定 object_keyFAA 拒絕其他 method 或 key
  • visionA-backend 必須先做 ownership 檢查job_id ↔ user_id mapping才換 token
  • frontend 不可快取 download_url每次「下載」按鈕重打 backend 換新 token

2.4.6 Trust Boundary

邊界 信任方向 守護機制
Browser → visionA-backend visionA-backend 不信任 browser 帶的 user_id / object_key 一律從 OIDC cookie session 取 user_idobject_key 從內部 mapping 反查
visionA-backend → converter converter 完全信任 visionA-backend 帶的 user_id visionA-backend 是唯一灌 user_id 的點multipart streaming 重組時黑名單 client 帶的 user_id
visionA-backend → FAA FAA 不認 visionA 而是線上跟 MC validate token service token (s2s pull) + delegated token (browser direct) 都走 MC
visionA-backend ↔ MC MC 用 client_secret 認 visionA 服務身份 標準 OAuth 2.0 client_credentials

3. 傳輸加密

連線 加密 備註
Browser → api-server HTTPS Phase 1雛形 HTTP
Browser → api-server WS WSS 同上
api-server ↔ remote-proxy內部 HTTP HTTPVPC-only 如 public 需 mTLS
Local agent → remote-proxy WSS Phase 1雛形 WS
Local agent → Kneron USB 無(硬體直連)

4. Rate LimitingPhase 1

端點 限制
POST /api/auth/login 5 / min / IP
POST /api/pairing/token 5 / min / user
WS /tunnel/connect 10 / min / IPtoken 級限制另計
一般 /api/* 100 / min / user

雛形不做。記錄為 TODO。


5. CORS

// internal/api/middleware/cors.go
func CORSMiddleware(allowedOrigins []string) gin.HandlerFunc {
    return func(c *gin.Context) {
        origin := c.Request.Header.Get("Origin")
        if allowed(origin, allowedOrigins) {
            c.Header("Access-Control-Allow-Origin", origin)
            c.Header("Access-Control-Allow-Credentials", "true")
        }
        c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
        c.Header("Access-Control-Max-Age", "86400")
        if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204); return }
        c.Next()
    }
}

雛形allowedOrigins = ["*"](全放) Phase 1:白名單 ["https://app.visiona.cloud"]


6. WebSocket Origin Check

Browser 連 /ws/* 時,遵循 CORS origin checklocal-tool 的 origin.go 已成熟;搬過來)。

/tunnel/connect 是 local agent 連進來(非瀏覽器),Origin check 跳過(該 endpoint 僅 token 驗證)。


7. CSRF

  • JWT-in-header非 cookie→ 天然免疫 CSRF
  • 若 Phase 1 用 session cookie
    • SameSite=Lax cookie
    • CSRF-Token headerdouble-submit pattern

Phase 0.6 OIDC BFF 落地後OB1-OB6Cookie session 設計做出以下取捨:

Pending 與 Logged-in Session 共用同一個 CookieADR-012

oidc-tdd.md §4.5 原設計示意了兩個 cookievisiona_pending_sid 短 TTL + visiona_session 長 TTL。實作時為了減少 cookie 數量與 handler 邏輯,改採合一:兩種狀態共用同一個 visiona_session cookie + 同一個 usersession.Store recordSession.UserID 是否為空區分階段。

安全防護三道線

# 防線 位置 說明
1 Middleware 強制檢查 UserID 空 → 401 internal/api/middleware.go AuthMiddleware Pending session 訪 protected endpoint 一律拒絕;該檔 169 行附「安全臨界檢查」註解明確警告不可拿掉
2 Login 完成後 rotate session ID internal/usersession/manager.go RotateSessionID + internal/api/oidc_auth.go callback OWASP ASVS V3.2.1 — 防止攻擊者預先誘騙受害者使用攻擊者 cookie 走完 OIDC flow
3 Pending state 在 callback 同一次 UpdateSession 清空 internal/api/oidc_auth.go callback OIDCState / OIDCNonce / OIDCCodeVerifier / Extra["return_to"] 在寫 user info 同一次 commit 中清掉

詳見 adr-012-pending-session-shared-cookie.md

Session ID Rotation 觸發點

事件 是否 rotate 理由
登入完成OIDC callback rotate OWASP ASVS V3.2.1,防 session fixation
提權(雛形無此情境) Phase 1 接 RBAC 後可能需要
Logout (直接刪除) 不需要 rotate整個 session record 從 store 消除
一般 API 請求 維持 session ID 穩定,只刷 LastSeenAt
屬性 理由
HttpOnly true 阻止 JS 讀取XSS 防護)
Secure trueprod/ falsedev HTTP 只在 HTTPS 傳送
SameSite Lax CSRF 基線防護Strict 會擋掉 OIDC callback redirect
Path / 全站可用
MaxAge 86400雛形 24h Phase 1 將擴至 7d 並接 refresh token
HMAC-SHA256 簽章 SigningKey ≥ 32 bytes 防 cookie 偽造NewManager 啟動時強制檢查

8. 輸入驗證

  • API body 用 binding:"required" + validator.v10
  • 檔案上傳:
    • Size 限制(雛形:模型 500MB 上限)
    • Content-Type 白名單(application/octet-stream, application/x-nef 等)
    • Magic bytes 檢查Phase 1
  • Pairing token^vAc_[0-9a-f]{32}$
  • Session token^vAs_[0-9a-f]{64}$
  • 傳入前先 .replace(/\s/g, '') 正規化(允許使用者貼上含空格的顯示格式)
  • SQL一律用參數化 query$1 / prepared statement

9. Secret 管理

Secret 雛形 Phase 1
Pairing tokenstatic env var DBhash
Storage signing key env var env var + 定期 rotation
DB password AWS Secrets Manager / Vault
JWT signing key 同上
S3 credentials env vardev IAM roleEC2 / K8s SA

禁止:任何 secret 寫死進程式碼 / commit 到 Git。.gitignore 必須含 .envdata/*.pem


10. 審計 LogPhase 1

關鍵事件記入 audit_logs 表:

  • 使用者登入 / 登出 / 密碼變更
  • Pairing token 建立 / 撤銷 / 使用
  • Device 建立 / 刪除
  • Model 上傳 / 刪除
  • Cluster 建立 / 推論

欄位:id, user_id, action, resource, details_json, ip, user_agent, created_at


11. 第三方依賴安全

  • go mod auditGo 1.26+)或 govulncheck 定期掃
  • npm: npm audit / Dependabot
  • Renovate / Dependabot 自動 PR
  • CI 時 fail on known CVE

雛形不強制,但建議 CI 設定。


12. 威脅模型STRIDE 摘要)

威脅 雛形影響 Phase 1 防護
Spoofing — 冒用 pairing token Dev 環境可接受 DB-backed + 兩階段 token + rate limit
Tampering — 中間人改 payload 無 TLS局網 OK公網不行 全程 TLS
Repudiation — 否認操作 無 audit log audit_logs 表
Info Disclosure — token / model 洩漏 env 獨立隔離 TLS + DB 加密 + presigned URL 有限 TTL
DoS — 灌爆 tunnel 無防護 rate limit + connection pool
Elevation of Privilege — 讀他人 device OIDC 已實裝OB5 後 user_id = OIDC sub RBAC + repository 查詢一律帶 WHERE owner_user_id = ?

14. 前端雛形安全債Phase 1 必還)

F5F6 實作留下的已知安全債。雛形階段可接受開發者自用、local 後端),但 Phase 1 對外開放前必須逐項處理。

# 項目 現況(雛形) Phase 1 處理 對應程式碼
14.1 Access token 存 localStorage visionA.auth.token key 寫入 localStorage被同網域任何 XSS 讀走 2026-04-26 已修OF2Phase 0.6 OIDC BFF 後frontend 不再持有 token改用 HttpOnly; Secure; SameSite=Laxvisiona_session cookie + server-side sessionin-memory。auth-store 改為 me-basedGET /api/auth/meapi client 以 credentials: 'include' 自動帶 cookie。 src/stores/auth-store.tssrc/lib/api.tsOF2 重寫)
14.2 Refresh token 未接上 login response 的 refresh_token 目前被丟棄access token 過期使用者被迫重登 接 refresh 流程access token 短 TTL ~15min、refresh 長 TTL走 cookie src/stores/auth-store.ts:137-173
14.3 WS token 走 querystring ws://.../ws/xxx?token=<JWT> — 明文進 server access logNginx / LB / Cloudflare歷史 log 含 token 字串 Sec-WebSocket-Protocol: Bearer,<token> subprotocol或 HTTP 先換 ~60s ticket 再 ?ticket=... src/hooks/use-websocket.ts:88-99
14.4 Device session 識別與 pairing token 型別未分離 session-store 的 activeSessionToken 命名容易混淆 pairing / session token 語意 拆為 activeDeviceSessionId(不是 token 而是 id明確區分「識別」與「密鑰」 src/stores/session-store.ts:47-52
14.5 CSP 尚未設定 Next.js 預設無 CSP header 加 strict CSPscript-src 'self' 'nonce-xxx');禁止 inline eval next.config.ts(尚未存在)
14.6 CSRF 防護 無(因尚未有瀏覽器端 cookie auth 切到 cookie auth 時補上 double-submit token 或 SameSite=Strict 全域 middleware

追蹤責任:每一項在 Phase 1 Sprint Planning 前必須有對應 ticket不允許以「雛形 OK」為由直接上線。


13. 合規

雛形期不處理 GDPR / CCPA。Phase 1 上線前必做:

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • 資料匯出 / 刪除GDPR right to access / erasure
  • 若有歐洲用戶DPA with processorsAWS / S3 供應商 / Email 供應商)

雛形實作重點

  • StaticPairingStore + env token
  • StaticAuthService + hard-coded demo user已替換為 OIDCPhase 0.6 / OB5見 oidc-tdd.md
  • CORS 限定白名單OB5 已調整)
  • HTTPdev/ HTTPSprod 必須)

Phase 1 必做

  • 兩階段 token
  • 真實 AuthClerk 或 自建)已完成Member Center OIDC
  • HTTPS / WSSprod 強制)
  • Rate limit
  • Audit log
  • Refresh token rotationMember Center 暫無 refresh雛形可接受
  • Redis / DB-backed session store取代 in-memory