// Package auth 提供内存会话与 CSRF 防护。会话不落库,服务重启会安全地使所有登录失效。 package auth import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "io" "net/http" "strconv" "strings" "sync" "time" ) const ( CookieName = "cmbuyer_session" SessionLifetime = 8 * time.Hour csrfTokenByteSize = 32 ) type session struct { csrfToken string authenticated bool expiresAt time.Time } // Manager 签发、验证并撤销进程内会话。cookie 仅承载经过 HMAC 签名的随机 session ID。 type Manager struct { secret []byte cookieSecure bool now func() time.Time random io.Reader mu sync.Mutex sessions map[string]session } // NewManager 创建会话管理器。secret 在启动时已由 config 验证为足够长度。 func NewManager(secret []byte, cookieSecure bool) *Manager { return &Manager{ secret: append([]byte(nil), secret...), cookieSecure: cookieSecure, now: time.Now, random: rand.Reader, sessions: make(map[string]session), } } // Ensure 返回当前有效会话;不存在或过期时签发匿名会话,以保护登录表单本身的 POST。 func (manager *Manager) Ensure(writer http.ResponseWriter, request *http.Request) (csrfToken string, authenticated bool) { if id, current, ok := manager.current(request); ok { return current.csrfToken, current.authenticated } else if id != "" { manager.delete(id) } id, current := manager.create(false) manager.writeCookie(writer, id, current.expiresAt) return current.csrfToken, false } // IsAuthenticated 只读检查当前请求是否持有有效管理会话;它不会像 Ensure 一样创建匿名会话。 func (manager *Manager) IsAuthenticated(request *http.Request) bool { _, current, found := manager.current(request) return found && current.authenticated } // VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。 func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) { _, current, found := manager.current(request) if !found || token == "" { return false, false } if subtle.ConstantTimeCompare([]byte(current.csrfToken), []byte(token)) != 1 { return false, false } return current.authenticated, true } // RotateAuthenticated 在登录成功后撤销旧会话并签发全新认证会话,避免 session fixation 与 CSRF 复用。 func (manager *Manager) RotateAuthenticated(writer http.ResponseWriter, request *http.Request) string { if id, _, ok := manager.current(request); ok { manager.delete(id) } id, current := manager.create(true) manager.writeCookie(writer, id, current.expiresAt) return current.csrfToken } // Logout 撤销当前会话并立即清除浏览器 cookie。 func (manager *Manager) Logout(writer http.ResponseWriter, request *http.Request) { if id, _, ok := manager.current(request); ok { manager.delete(id) } http.SetCookie(writer, &http.Cookie{ Name: CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: manager.cookieSecure, SameSite: http.SameSiteLaxMode, }) } func (manager *Manager) current(request *http.Request) (string, session, bool) { cookie, err := request.Cookie(CookieName) if err != nil { return "", session{}, false } id, expiresAt, ok := manager.verifyCookie(cookie.Value) if !ok || !manager.now().Before(expiresAt) { return id, session{}, false } manager.mu.Lock() defer manager.mu.Unlock() current, found := manager.sessions[id] if !found || !manager.now().Before(current.expiresAt) { return id, session{}, false } return id, current, true } func (manager *Manager) create(authenticated bool) (string, session) { id := manager.randomToken() current := session{ csrfToken: manager.randomToken(), authenticated: authenticated, expiresAt: manager.now().Add(SessionLifetime), } manager.mu.Lock() manager.sessions[id] = current manager.mu.Unlock() return id, current } func (manager *Manager) delete(id string) { manager.mu.Lock() delete(manager.sessions, id) manager.mu.Unlock() } func (manager *Manager) randomToken() string { bytes := make([]byte, csrfTokenByteSize) if _, err := io.ReadFull(manager.random, bytes); err != nil { panic("crypto/rand failed while creating a session token") } return base64.RawURLEncoding.EncodeToString(bytes) } func (manager *Manager) writeCookie(writer http.ResponseWriter, id string, expiresAt time.Time) { http.SetCookie(writer, &http.Cookie{ Name: CookieName, Value: manager.signCookie(id, expiresAt), Path: "/", MaxAge: int(expiresAt.Sub(manager.now()).Seconds()), Expires: expiresAt, HttpOnly: true, Secure: manager.cookieSecure, SameSite: http.SameSiteLaxMode, }) } func (manager *Manager) signCookie(id string, expiresAt time.Time) string { payload := id + "." + strconv.FormatInt(expiresAt.Unix(), 10) mac := hmac.New(sha256.New, manager.secret) _, _ = mac.Write([]byte(payload)) return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } func (manager *Manager) verifyCookie(value string) (string, time.Time, bool) { parts := strings.Split(value, ".") if len(parts) != 3 || parts[0] == "" { return "", time.Time{}, false } expiresUnix, err := strconv.ParseInt(parts[1], 10, 64) if err != nil { return "", time.Time{}, false } provided, err := base64.RawURLEncoding.DecodeString(parts[2]) if err != nil { return "", time.Time{}, false } payload := parts[0] + "." + parts[1] mac := hmac.New(sha256.New, manager.secret) _, _ = mac.Write([]byte(payload)) if !hmac.Equal(provided, mac.Sum(nil)) { return "", time.Time{}, false } return parts[0], time.Unix(expiresUnix, 0), true }