205 lines
5.7 KiB
Go
205 lines
5.7 KiB
Go
// 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
|
|
}
|