從 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>
176 lines
5.7 KiB
Go
176 lines
5.7 KiB
Go
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 = true(prod HTTPS)/ false(dev HTTP)
|
||
// HTTPOnly = true(永遠)
|
||
// SameSite = http.SameSiteLaxMode
|
||
// MaxAge = 86400(雛形 24h;TDD §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 // 是否要求 HTTPS(dev=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 必須是 base64url(NewInMemoryStore.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(),
|
||
})
|
||
}
|