依 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)。
22 KiB
Security — 安全考量與 Pairing Token 協定
本文件彙整所有安全相關細節。部分對應 Design Doc §6。
1. Pairing Token 協定
1.1 雛形版(v0.1)
最簡化單一 token:
[開發者 setup]
# 產生符合正式格式的雛形 token:vAc_ + 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_*格式就接受) - 只適合 dev;Phase 1 切入正式 DB-backed 前,必須先改為 Stage 1 + Stage 2 兩階段(見 §1.2、ADR-003)
1.2 Phase 1:DB-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 Token(config / keychain),丟棄 Pairing Token(已作廢)。日後重連一律用 Session Token;kind='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-Credential(pairing),共 36 字元
vAs_[0-9a-f]{64} # Agent-Session(session),共 68 字元
- 字元集:小寫 hex + 底線前綴(不用 base64url,避免 URL-safe 混淆 / 大小寫歧義)
- 產生:
crypto/rand.Read→hex.EncodeToString - API 回傳純字串,無空格、無分隔符(前端 UI 顯示時可每 8 字元插空格提升可讀性;複製/貼上時前端需
.replace(/\s/g, '')正規化) - 正則驗證:
- Pairing:
^vAc_[0-9a-f]{32}$ - Session:
^vAs_[0-9a-f]{64}$
- Pairing:
- 前綴讓 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) → UserContext、Authorize(ctx, resource, action) |
AuthProvider |
Handler 層 | 使用者登入 / 註冊 / 登出等明確動作 | Register、Login、Logout、ValidateToken、GetUser |
雙層並存原因:
AuthService著重「這個 request 代表哪位使用者」(狀態萃取),每個 request 都要跑AuthProvider著重「使用者的 Auth 生命週期管理」(帳號 CRUD + token 簽發),只在登入登出等動作發生
實作時通常一個底層 provider(例:Clerk / OIDC client)會同時被 AuthProvider 與 AuthService 包裝;但 interface 分開讓 handler 不需要知道 middleware 的細節。
// internal/auth/service.go(middleware 層)
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.go(handler 層,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), 詳見:
- adr-010-oidc-bff.md(接入策略)
- adr-011-supersede-adr-005.md(推翻決策)
- oidc-tdd.md(實作細節)
demo-user 仍可用 — Member Center 端 seed
demo@visionA.local / demo123帳號。
歷史紀錄(已過時):
/api/auth/login、/api/auth/register曾由StaticAuthProvider實作:Login(email, password)— 任何帳密都通過,回demo-user+ 假的 access token(demo-access-token)Register(...)— stub,回ErrNotImplemented(前端顯示「即將推出」)Logout(...)— stub(清前端 store 即可)ValidateToken("demo-access-token")→ demo-user;其他 token →ErrInvalidTokenGetUser("demo-user")→ demo-user struct;其他 →ErrNotFound
StaticAuthService.Authenticate永遠回demo-user(middleware 層;與 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 機制
- JWT(含
sub=user_id,exp,roles)放Authorization: Bearer ...header - Refresh token 存 HttpOnly cookie(避免 XSS 拿到)
- Logout:清 cookie + JWT 黑名單(Redis)
2.4 Service-to-Service Token(Phase 0.8 啟用)
對齊
adr/adr-014-conversion-integration.md與conversion.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 }
cache(exp - 15s 內可重用)
2.4.2 Cache 策略
- 單一 token cache 涵蓋全部 4 個 scope(MC 端發單一 token 含全部)
- cache 在 visionA-backend 進程記憶體(
sync.RWMutex保護) exp - 15s提前重取,避免下游使用時剛好過期- 併發保護:double-checked locking(第一次 cache miss 時其他 goroutine 等待)
- 重啟即清空(in-memory,無持久化);下次需要時重新取
2.4.3 失敗處理
| 場景 | 處理 |
|---|---|
| 4xx(client_id / scope 設定錯) | fatal:log + 5xx response 給上游 caller,不重試 |
| 5xx / network | 指數退避 max 2 次(1s, 2s) |
| Token cache 命中 → 但 MC 已 invalidate(rare) | 下次 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-backend;in-memory cache 自然失效
2.4.5 Delegated Download Token
不同於上面的 service token,FAA 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-900s,由
VISIONA_FAA_DELEGATED_TTL_SECONDS控制) - 綁定 method=GET + 特定 object_key(FAA 拒絕其他 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_id;object_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) | HTTP(VPC-only) | 如 public 需 mTLS |
| Local agent → remote-proxy | WSS | Phase 1;雛形 WS |
| Local agent → Kneron USB | 無(硬體直連) | — |
4. Rate Limiting(Phase 1)
| 端點 | 限制 |
|---|---|
POST /api/auth/login |
5 / min / IP |
POST /api/pairing/token |
5 / min / user |
WS /tunnel/connect |
10 / min / IP;token 級限制另計 |
一般 /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 check(local-tool 的 origin.go 已成熟;搬過來)。
/tunnel/connect 是 local agent 連進來(非瀏覽器),Origin check 跳過(該 endpoint 僅 token 驗證)。
7. CSRF
- JWT-in-header(非 cookie)→ 天然免疫 CSRF
- 若 Phase 1 用 session cookie,加:
SameSite=LaxcookieCSRF-Tokenheader(double-submit pattern)
7.1 Cookie Session 設計取捨(Phase 0.6 OIDC BFF)
Phase 0.6 OIDC BFF 落地後(OB1-OB6),Cookie session 設計做出以下取捨:
Pending 與 Logged-in Session 共用同一個 Cookie(ADR-012)
oidc-tdd.md §4.5 原設計示意了兩個 cookie(visiona_pending_sid 短 TTL + visiona_session 長 TTL)。實作時為了減少 cookie 數量與 handler 邏輯,改採合一:兩種狀態共用同一個 visiona_session cookie + 同一個 usersession.Store record,由 Session.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 |
Cookie 屬性最小集(雛形 + Phase 1 共通)
| 屬性 | 值 | 理由 |
|---|---|---|
HttpOnly |
true | 阻止 JS 讀取(XSS 防護) |
Secure |
true(prod)/ false(dev 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 token(static) | env var | DB(hash) |
| Storage signing key | env var | env var + 定期 rotation |
| DB password | — | AWS Secrets Manager / Vault |
| JWT signing key | — | 同上 |
| S3 credentials | env var(dev) | IAM role(EC2 / K8s SA) |
禁止:任何 secret 寫死進程式碼 / commit 到 Git。.gitignore 必須含 .env、data/、*.pem。
10. 審計 Log(Phase 1)
關鍵事件記入 audit_logs 表:
- 使用者登入 / 登出 / 密碼變更
- Pairing token 建立 / 撤銷 / 使用
- Device 建立 / 刪除
- Model 上傳 / 刪除
- Cluster 建立 / 推論
欄位:id, user_id, action, resource, details_json, ip, user_agent, created_at
11. 第三方依賴安全
go mod audit(Go 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 必還)
F5–F6 實作留下的已知安全債。雛形階段可接受(開發者自用、local 後端),但 Phase 1 對外開放前必須逐項處理。
| # | 項目 | 現況(雛形) | Phase 1 處理 | 對應程式碼 |
|---|---|---|---|---|
| 14.1 | visionA.auth.token key 寫入 localStorage,被同網域任何 XSS 讀走HttpOnly; Secure; SameSite=Lax 的 visiona_session cookie + server-side session(in-memory)。auth-store 改為 me-based(GET /api/auth/me),api client 以 credentials: 'include' 自動帶 cookie。 |
— | src/stores/auth-store.ts、src/lib/api.ts(OF2 重寫) |
|
| 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 log(Nginx / 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 CSP(script-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 processors(AWS / S3 供應商 / Email 供應商)
雛形實作重點:
StaticPairingStore+ env token→ 已替換為 OIDC(Phase 0.6 / OB5;見 oidc-tdd.md)StaticAuthService+ hard-coded demo user- CORS 限定白名單(OB5 已調整)
- HTTP(dev)/ HTTPS(prod 必須)
Phase 1 必做:
- 兩階段 token
真實 Auth(Clerk 或 自建)→ 已完成(Member Center OIDC)- HTTPS / WSS(prod 強制)
- Rate limit
- Audit log
- Refresh token rotation(Member Center 暫無 refresh,雛形可接受)
- Redis / DB-backed session store(取代 in-memory)