依 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)。
513 lines
22 KiB
Markdown
513 lines
22 KiB
Markdown
# Security — 安全考量與 Pairing Token 協定
|
||
|
||
> 本文件彙整所有安全相關細節。部分對應 Design Doc §6。
|
||
|
||
---
|
||
|
||
## 1. Pairing Token 協定 {#pairing-token}
|
||
|
||
### 1.1 雛形版(v0.1)
|
||
|
||
最簡化單一 token:
|
||
|
||
```bash
|
||
[開發者 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}$`
|
||
- 前綴讓 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 的細節。
|
||
|
||
```go
|
||
// 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/adr-010-oidc-bff.md)(接入策略)
|
||
> - [adr-011-supersede-adr-005.md](./adr/adr-011-supersede-adr-005.md)(推翻決策)
|
||
> - [oidc-tdd.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 → `ErrInvalidToken`
|
||
- `GetUser("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`](./adr/adr-014-conversion-integration.md) 與 [`conversion.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
|
||
|
||
```go
|
||
// 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=Lax` cookie
|
||
- `CSRF-Token` header(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 | ~~Access token 存 localStorage~~ | ~~`visionA.auth.token` key 寫入 localStorage,被同網域任何 XSS 讀走~~ **2026-04-26 已修(OF2)**:Phase 0.6 OIDC BFF 後,frontend 不再持有 token;改用 `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
|
||
- ~~`StaticAuthService` + hard-coded demo user~~ → **已替換為 OIDC**(Phase 0.6 / OB5;見 oidc-tdd.md)
|
||
- 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)
|