This commit is contained in:
2026-10-05 07:40:34 +08:00
parent 5a7ac2e5d9
commit d13088bfd5
22 changed files with 1369 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
// 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
}