從 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>
199 lines
8.0 KiB
Go
199 lines
8.0 KiB
Go
// all_endpoints_require_auth_test.go — Phase 0.7 security regression test.
|
||
//
|
||
// 對齊 .autoflow/05-implementation/review/phase-0.7-security-audit.md s1。
|
||
//
|
||
// 目的:對所有 protected endpoint 發無 cookie request,必須回 401。
|
||
//
|
||
// 為什麼需要:
|
||
//
|
||
// Phase 0.7 audit 發現 visionA-backend 13+ 處 handler 用 resolveUserID 寬鬆 fallback
|
||
// 到 demo-user。即使 Backend 完成 Fix #1-#5(移除 fallback、改 strict mode)後,
|
||
// 未來任何一條漏套 AuthMiddleware 的新 endpoint 都會立刻打破 multi-tenant 隔離。
|
||
//
|
||
// 這條測試是 C1 fallback 的長期防呆 — 任何未來 endpoint 漏保護都會立刻被 fail:
|
||
//
|
||
// - 新 endpoint 註冊時忘了套 middleware
|
||
// - middleware 順序錯誤
|
||
// - router group 套錯(例如把保護的 path 加在 r 而非 apiGroup)
|
||
//
|
||
// 能力與限制:
|
||
//
|
||
// ✅ 能驗:每個 protected endpoint 都有套 AuthMiddleware(沒帶 cookie → 401)
|
||
// ✅ 能驗:未來新增 endpoint 漏套會立刻 fail
|
||
// ❌ 不能驗:middleware 內部邏輯(middleware_test.go 在做)
|
||
// ❌ 不能驗:跨用戶 authorization(user A 不能存取 B 的資源 — 另一條測試)
|
||
package main
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// publicPaths 是「不需要認證」的明確 endpoint 清單(method + path 精確匹配)。
|
||
//
|
||
// 對齊 audit 報告 endpoint × middleware 對照表:這些 endpoint 故意設計成 public —
|
||
//
|
||
// - /healthz → K8s liveness/readiness 用,不能要 cookie
|
||
// - /api/auth/login → 起始 OIDC 登入流程,user 還沒登入
|
||
// - /api/auth/callback → OIDC IdP 302 回來,user 還沒登入
|
||
// - /api/pairing/exchange → agent 還沒 session token,用 pairing token 換
|
||
//
|
||
// 任何往這份清單裡新加 endpoint 的 PR 都該特別 review — 你正在繞過 OIDC 保護。
|
||
var publicPaths = map[string]bool{
|
||
"GET /healthz": true,
|
||
"GET /api/auth/login": true,
|
||
"GET /api/auth/callback": true,
|
||
"POST /api/pairing/exchange": true,
|
||
}
|
||
|
||
// publicPrefixes 是「整個 path prefix 都不走 OIDC AuthMiddleware」的清單。
|
||
//
|
||
// - /storage/* — 用 HMAC presigned URL 驗簽(api-spec.md §10),不是 cookie
|
||
// - /ws/* — 雛形 stub 一律 501,註冊在 r 而非 apiGroup(stubs.go:70-85)。
|
||
// 目前無認證 → 501;**未來補實作 WebSocket proxy 時必須套 auth**,
|
||
// 屆時應從這份清單移除。TODO(B7): 移到 protected。
|
||
var publicPrefixes = []string{
|
||
"/storage/",
|
||
"/ws/",
|
||
}
|
||
|
||
// pathParamReplacements 把 gin route 的 path param(:id / :token / *filepath)
|
||
// 換成具體值,讓 router 能 match 到實際 handler。
|
||
//
|
||
// 注意:這裡的具體值不需要是「資料庫真的存在的 ID」 — 我們只在乎 router 路由正確
|
||
// (middleware 是 router-level 的,沒到 handler 之前就會被 401 擋下)。
|
||
func replacePathParams(path string) string {
|
||
// 順序很重要:*filepath 用 catch-all,要先處理;其他 :param 用 simple replace。
|
||
if idx := strings.Index(path, "*"); idx >= 0 {
|
||
// 例:/storage/*filepath → /storage/anything
|
||
return path[:idx] + "anything"
|
||
}
|
||
|
||
// :param → "test-value"(任意非空字串就行)
|
||
parts := strings.Split(path, "/")
|
||
for i, p := range parts {
|
||
if strings.HasPrefix(p, ":") {
|
||
parts[i] = "test-value"
|
||
}
|
||
}
|
||
return strings.Join(parts, "/")
|
||
}
|
||
|
||
// TestAllAPIEndpointsRequire401WithoutCookie 對所有 protected endpoint 發無 cookie
|
||
// request,必須回 401。
|
||
//
|
||
// Phase 0.7 security regression test (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md s1)。
|
||
//
|
||
// 流程:
|
||
// 1. 從 fixture 拿 *gin.Engine,列出所有註冊的 routes
|
||
// 2. 對每條 route:跳過 publicPaths / publicPrefixes 名單;其他全部要求 401
|
||
// 3. 發 request 時不帶 cookie、不帶 Authorization header
|
||
// 4. 驗 status code == 401 UNAUTHORIZED
|
||
//
|
||
// 失敗時的解讀:
|
||
// - 某個 protected endpoint 回 200/500/501 而非 401 → 該路徑沒套 AuthMiddleware;
|
||
// 檢查是不是註冊在 r 而非 apiGroup,或 middleware 順序錯誤。
|
||
// - 某個 endpoint 回 200 帶 demo-user 資料 → C1 fallback 還在沒移除(Backend Fix #1-#5
|
||
// 未完成)。應在 Backend 修完後再跑,或先標 t.Skip。
|
||
func TestAllAPIEndpointsRequire401WithoutCookie(t *testing.T) {
|
||
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||
defer f.Close()
|
||
|
||
require.NotNil(t, f.router, "fixture.router 必須非 nil — 確認 setupFixture 有 set router")
|
||
|
||
routes := f.router.Routes()
|
||
require.NotEmpty(t, routes, "router 應至少註冊一條 route")
|
||
|
||
var (
|
||
coveredCount int // 真正測 401 的數量
|
||
skippedCount int // 跳過(public)的數量
|
||
)
|
||
|
||
for _, route := range routes {
|
||
key := route.Method + " " + route.Path
|
||
|
||
// 跳過明確 public 的 endpoint
|
||
if publicPaths[key] {
|
||
skippedCount++
|
||
continue
|
||
}
|
||
skipByPrefix := false
|
||
for _, p := range publicPrefixes {
|
||
if strings.HasPrefix(route.Path, p) {
|
||
skipByPrefix = true
|
||
break
|
||
}
|
||
}
|
||
if skipByPrefix {
|
||
skippedCount++
|
||
continue
|
||
}
|
||
|
||
coveredCount++
|
||
|
||
// 用 t.Run 讓每個 endpoint 是獨立 subtest — 失敗時看得到具體哪條
|
||
t.Run(key, func(t *testing.T) {
|
||
actualPath := replacePathParams(route.Path)
|
||
|
||
req := httptest.NewRequest(route.Method, actualPath, nil)
|
||
// **故意不**設 cookie、不設 Authorization header — 模擬完全沒認證
|
||
w := httptest.NewRecorder()
|
||
|
||
f.router.ServeHTTP(w, req)
|
||
|
||
// 必須是 401。不可以是:
|
||
// - 200 / 2xx → 通過 middleware,handler 拿 fallback userID 回了資料(C1 latent break)
|
||
// - 500 → middleware 沒套或內部 panic(更糟)
|
||
// - 501 → handler 是 stub 但 middleware 沒擋下(middleware 沒套)
|
||
// - 404 → router path mismatch(測試 setup bug)
|
||
// - 502 → proxy handler 沒被 middleware 擋下(middleware 沒套)
|
||
assert.Equal(t, http.StatusUnauthorized, w.Code,
|
||
"%s 應回 401(沒帶 cookie),實際 %d;body=%s\n"+
|
||
"可能原因:\n"+
|
||
" 1. 該路徑沒套 AuthMiddleware(檢查 NewRouter 是註冊在 r 還是 apiGroup)\n"+
|
||
" 2. middleware 註冊順序錯誤(AuthMiddleware 必須在 handler 之前)\n"+
|
||
" 3. C1 fallback 還在 — Backend Fix #1-#5 未完成(看 audit 報告)",
|
||
key, w.Code, w.Body.String())
|
||
})
|
||
}
|
||
|
||
// 確保我們真的有測到 endpoint,不是測試 setup 出錯導致全部 skip
|
||
require.Greater(t, coveredCount, 10,
|
||
"預期至少 10+ 個 protected endpoint 被 cover;實際 covered=%d, skipped=%d。"+
|
||
"若異常偏低代表 fixture 或路由註冊出問題", coveredCount, skippedCount)
|
||
|
||
t.Logf("covered %d protected endpoints, skipped %d public endpoints",
|
||
coveredCount, skippedCount)
|
||
}
|
||
|
||
// TestPublicEndpointsListIsExhaustive 防呆:確認 publicPaths 與 publicPrefixes
|
||
// 真的對應到實際 router 上有的 endpoint,而不是過期清單。
|
||
//
|
||
// 為什麼需要:如果未來把某個 public endpoint 改名(例 /api/auth/login → /api/auth/oidc/login)
|
||
// 但忘了更新 publicPaths,主測試會把新的 path 當 protected 然後驗 401。雖然該驗
|
||
// 也是對的(新 path 就是 protected),但會讓人誤以為「主測試覆蓋的 public 已經包含 login」。
|
||
//
|
||
// 這條測試讓「publicPaths 列了卻沒對應實際路由的 entry」變成 fail。
|
||
func TestPublicEndpointsListMatchesRouter(t *testing.T) {
|
||
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||
defer f.Close()
|
||
|
||
require.NotNil(t, f.router)
|
||
routes := f.router.Routes()
|
||
|
||
registered := make(map[string]bool, len(routes))
|
||
for _, r := range routes {
|
||
registered[r.Method+" "+r.Path] = true
|
||
}
|
||
|
||
for key := range publicPaths {
|
||
assert.True(t, registered[key],
|
||
"publicPaths 列了 %q 但 router 沒有這條 route — 是否已重新命名?", key)
|
||
}
|
||
}
|