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(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(), }) }