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

176 lines
5.7 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 usersession
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"net/http"
"strings"
)
// CookieConfig 集中描述 cookie 的所有屬性,避免 helper 函式裡塞太多參數。
//
// 對齊 oidc-tdd.md §5.1
//
// Name = "visiona_session"
// Domain = ".visiona.cloud"prod/ ""dev
// Path = "/"
// Secure = trueprod HTTPS/ falsedev HTTP
// HTTPOnly = true永遠
// SameSite = http.SameSiteLaxMode
// MaxAge = 86400雛形 24hTDD §5.1 是 7d雛形先以任務指定值為準
// SigningKey = ≥ 32 bytes 隨機HMAC-SHA256
type CookieConfig struct {
Name string // cookie 名稱;空字串會 fallback 到 DefaultCookieName
Domain string // production 設定(如 ".visiona.cloud"dev 留空
Path string // cookie 範圍;空字串會 fallback 到 "/"
Secure bool // 是否要求 HTTPSdev=false, prod=true
HTTPOnly bool // 是否禁止 JS 讀取(永遠應為 true
SameSite http.SameSite // 預設 http.SameSiteLaxMode
MaxAge int // cookie 存活秒數0 = session cookie負值 = 立即刪除
// SigningKey 是 HMAC-SHA256 的金鑰,**必填**,至少 32 bytes 才安全caller 自行確認)。
// 預設應由 env var VISIONA_SESSION_SECRET 注入,在 process startup 階段檢查長度。
SigningKey []byte
}
// DefaultCookieName 與 oidc-tdd.md §5.1 對齊。
const DefaultCookieName = "visiona_session"
// validate 檢查 CookieConfig 必填欄位。
//
// 不檢查 SigningKey 長度(由 caller 在 startup 階段確保 ≥ 32 bytes
// 此處不重複檢查避免每次 read/write 都做一次)。
func (c CookieConfig) validate() error {
if len(c.SigningKey) == 0 {
return ErrInvalidConfig
}
return nil
}
// resolvedName 回傳 c.Name 或 DefaultCookieName。
func (c CookieConfig) resolvedName() string {
if c.Name == "" {
return DefaultCookieName
}
return c.Name
}
// resolvedPath 回傳 c.Path 或 "/"。
func (c CookieConfig) resolvedPath() string {
if c.Path == "" {
return "/"
}
return c.Path
}
// resolvedSameSite 回傳 c.SameSite 或預設 Lax。
func (c CookieConfig) resolvedSameSite() http.SameSite {
if c.SameSite == 0 {
return http.SameSiteLaxMode
}
return c.SameSite
}
// signSessionID 用 HMAC-SHA256 產生簽章,回傳 base64url 編碼。
func signSessionID(sessionID string, key []byte) string {
h := hmac.New(sha256.New, key)
h.Write([]byte(sessionID))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
// EncodeCookieValue 將 sessionID 與 HMAC 簽章組成 cookie value。
//
// Format<sessionID>.<base64url(HMAC-SHA256(SigningKey, sessionID))>
//
// sessionID 必須是 base64urlNewInMemoryStore.Create 產生的格式),不可含 "."(會撞 separator
func EncodeCookieValue(sessionID string, key []byte) string {
return sessionID + "." + signSessionID(sessionID, key)
}
// DecodeCookieValue 解析 cookie value驗 HMAC 後回傳 sessionID。
//
// 任何 parse / sig 失敗都統一回 ErrInvalidCookie 或 ErrSignatureMismatch
// 避免攻擊者從錯誤訊息推斷 SigningKey 結構。
func DecodeCookieValue(value string, key []byte) (string, error) {
if value == "" {
return "", ErrInvalidCookie
}
// SplitN(2)sessionID 內絕對無 "."base64url 字元集為 A-Z a-z 0-9 - _所以唯一的 "." 就是 separator。
parts := strings.SplitN(value, ".", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", ErrInvalidCookie
}
sessionID, providedSig := parts[0], parts[1]
expectedSig := signSessionID(sessionID, key)
// 用常數時間比較避免 timing attack。
if subtle.ConstantTimeCompare([]byte(providedSig), []byte(expectedSig)) != 1 {
return "", ErrSignatureMismatch
}
return sessionID, nil
}
// WriteCookie 將 sessionID 簽章後寫入 Set-Cookie header。
//
// 失敗cfg.SigningKey 缺)回 ErrInvalidConfig。
// 為了讓 caller 在 handler 中乾淨呼叫,不對 w 做任何 status / body 操作。
func WriteCookie(w http.ResponseWriter, cfg CookieConfig, sessionID string) error {
if err := cfg.validate(); err != nil {
return err
}
if sessionID == "" {
return ErrInvalidCookie
}
http.SetCookie(w, &http.Cookie{
Name: cfg.resolvedName(),
Value: EncodeCookieValue(sessionID, cfg.SigningKey),
Path: cfg.resolvedPath(),
Domain: cfg.Domain,
MaxAge: cfg.MaxAge,
Secure: cfg.Secure,
HttpOnly: cfg.HTTPOnly,
SameSite: cfg.resolvedSameSite(),
})
return nil
}
// ReadCookie 從 request 取出 cookie驗簽回傳 sessionID。
//
// 找不到 cookie 回 (空, false)**不**當成 error。
// cookie 存在但 parse / sig 失敗回 (空, false)(同樣不揭露細節給 caller避免被當成 oracle
//
// 內部錯誤cfg.SigningKey 缺)回 (空, false),但 caller 應在 startup 時就避免。
func ReadCookie(r *http.Request, cfg CookieConfig) (sessionID string, ok bool) {
if cfg.validate() != nil {
return "", false
}
c, err := r.Cookie(cfg.resolvedName())
if err != nil {
return "", false
}
sid, err := DecodeCookieValue(c.Value, cfg.SigningKey)
if err != nil {
return "", false
}
return sid, true
}
// ClearCookie 寫一個過期的同名 cookie瀏覽器會刪除它。
//
// 必須使用與設定時「相同的 Name / Path / Domain」否則瀏覽器不會刪到正確的那一份
// RFC 6265
func ClearCookie(w http.ResponseWriter, cfg CookieConfig) {
http.SetCookie(w, &http.Cookie{
Name: cfg.resolvedName(),
Value: "",
Path: cfg.resolvedPath(),
Domain: cfg.Domain,
MaxAge: -1, // 過期,立即刪除
Secure: cfg.Secure,
HttpOnly: cfg.HTTPOnly,
SameSite: cfg.resolvedSameSite(),
})
}