110 lines
3.0 KiB
Go
110 lines
3.0 KiB
Go
// 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
|
||
}
|