// Package auth 提供以 HMAC 簽名 cookie 實作的輕量 session, // 不需額外的 session 儲存,適合目前無狀態的 SSR 架構。 package auth import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "net/http" "strconv" "strings" "time" ) const ( // CookieName session cookie 的名稱。 CookieName = "nestly_session" defaultTTL = 7 * 24 * time.Hour ) // ErrInvalidSession 代表 cookie 不存在、簽名不符或已過期; // 呼叫端可將其視為「未登入」而非錯誤。 var ErrInvalidSession = errors.New("session 無效或已過期") // SessionManager 以伺服器端密鑰簽發並驗證 session cookie。 // cookie 值格式為 "uid.expUnix.signature"(HMAC-SHA256), // 驗證僅保證簽名正確且未過期,帳號是否仍存在須由呼叫端查詢。 type SessionManager struct { secret []byte ttl time.Duration secure bool } // NewSessionManager 以 secret 簽章建立 SessionManager; // secure 控制 cookie 是否僅經 HTTPS 傳送(本機開發為 false)。 func NewSessionManager(secret string, secure bool) *SessionManager { return &SessionManager{ secret: []byte(secret), ttl: defaultTTL, secure: secure, } } func (m *SessionManager) sign(payload string) []byte { mac := hmac.New(sha256.New, m.secret) mac.Write([]byte(payload)) return mac.Sum(nil) } // Login 為 uid 簽發 session cookie。 func (m *SessionManager) Login(w http.ResponseWriter, uid uint) { payload := fmt.Sprintf("%d.%d", uid, time.Now().Add(m.ttl).Unix()) http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: payload + "." + hex.EncodeToString(m.sign(payload)), Path: "/", MaxAge: int(m.ttl.Seconds()), HttpOnly: true, Secure: m.secure, SameSite: http.SameSiteLaxMode, }) } // Logout 使 session cookie 立即失效。 func (m *SessionManager) Logout(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: m.secure, SameSite: http.SameSiteLaxMode, }) } // UserID 驗證請求中的 session cookie 並取出使用者 ID。 func (m *SessionManager) UserID(r *http.Request) (uint, error) { c, err := r.Cookie(CookieName) if err != nil || c.Value == "" { return 0, ErrInvalidSession } // 值格式為 uid.exp.sig,簽名為最後一段,從右側切分以免誤切數值中的點。 dot := strings.LastIndexByte(c.Value, '.') if dot <= 0 { return 0, ErrInvalidSession } payload, sigHex := c.Value[:dot], c.Value[dot+1:] uidStr, expStr, ok := strings.Cut(payload, ".") if !ok { return 0, ErrInvalidSession } uid, err := strconv.ParseUint(uidStr, 10, 64) if err != nil { return 0, ErrInvalidSession } exp, err := strconv.ParseInt(expStr, 10, 64) if err != nil || time.Now().Unix() >= exp { return 0, ErrInvalidSession } sig, err := hex.DecodeString(sigHex) if err != nil || !hmac.Equal(sig, m.sign(payload)) { return 0, ErrInvalidSession } return uint(uid), nil }