Scert/backend/internal/bootstrap/session.go

235 lines
6.0 KiB
Go

package bootstrap
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"errors"
"net"
"net/http"
"strings"
"sync"
"time"
)
const (
SessionCookieName = "scert_bootstrap_session"
bootstrapTokenLifetime = 30 * time.Minute
bootstrapSessionLifetime = 30 * time.Minute
bootstrapSessionCapacity = 32
bootstrapExchangeInFlightLimit = 8
bootstrapSourceCapacity = 256
bootstrapSourceAttemptLimit = 6
bootstrapSourceAttemptWindow = time.Minute
bootstrapTokenSize = 32
bootstrapSessionTokenSize = 32
)
var readRandom = rand.Read
var (
ErrBootstrapTokenInvalid = errors.New("Bootstrap 一次性令牌无效或已经使用")
ErrBootstrapTokenExpired = errors.New("Bootstrap 一次性令牌已经过期,请重启服务生成新地址")
ErrBootstrapRateLimited = errors.New("Bootstrap 验证尝试过多,请稍后重试")
ErrBootstrapBusy = errors.New("Bootstrap 验证请求过多,请稍后重试")
ErrBootstrapSessionFull = errors.New("Bootstrap 会话数量已达上限,请等待现有会话过期后重试")
)
type BootstrapSession struct {
Token string
ExpiresAt time.Time
}
type bootstrapSessionRecord struct {
ExpiresAt time.Time
}
type bootstrapSourceAttempts struct {
WindowStarted time.Time
Count int
}
type SessionManager struct {
mu sync.Mutex
now func() time.Time
tokenHash [sha256.Size]byte
tokenExpiry time.Time
tokenUsed bool
sessions map[[sha256.Size]byte]bootstrapSessionRecord
sources map[string]bootstrapSourceAttempts
inFlight int
}
func NewSessionManager() (*SessionManager, string, error) {
token, err := secureRandomToken(bootstrapTokenSize)
if err != nil {
return nil, "", err
}
now := time.Now().UTC()
return &SessionManager{
now: time.Now,
tokenHash: sha256.Sum256([]byte(token)),
tokenExpiry: now.Add(bootstrapTokenLifetime),
sessions: make(map[[sha256.Size]byte]bootstrapSessionRecord),
sources: make(map[string]bootstrapSourceAttempts),
}, token, nil
}
func (m *SessionManager) Exchange(token, source string) (BootstrapSession, error) {
if m == nil {
return BootstrapSession{}, errors.New("Bootstrap 会话服务尚未初始化")
}
source = normalizeBootstrapSource(source)
if source == "" {
return BootstrapSession{}, errors.New("Bootstrap 请求来源无效")
}
now := m.now().UTC()
if err := m.reserveExchange(source, now); err != nil {
return BootstrapSession{}, err
}
defer m.releaseExchange()
candidateHash := sha256.Sum256([]byte(strings.TrimSpace(token)))
m.mu.Lock()
defer m.mu.Unlock()
m.pruneLocked(now)
if m.tokenUsed {
return BootstrapSession{}, ErrBootstrapTokenInvalid
}
if !m.tokenExpiry.After(now) {
return BootstrapSession{}, ErrBootstrapTokenExpired
}
if subtle.ConstantTimeCompare(candidateHash[:], m.tokenHash[:]) != 1 {
return BootstrapSession{}, ErrBootstrapTokenInvalid
}
if len(m.sessions) >= bootstrapSessionCapacity {
return BootstrapSession{}, ErrBootstrapSessionFull
}
sessionToken, err := secureRandomToken(bootstrapSessionTokenSize)
if err != nil {
return BootstrapSession{}, err
}
expiresAt := now.Add(bootstrapSessionLifetime)
m.sessions[sha256.Sum256([]byte(sessionToken))] = bootstrapSessionRecord{ExpiresAt: expiresAt}
m.tokenUsed = true
return BootstrapSession{Token: sessionToken, ExpiresAt: expiresAt}, nil
}
func (m *SessionManager) Authenticate(token string) bool {
if m == nil || strings.TrimSpace(token) == "" {
return false
}
now := m.now().UTC()
hash := sha256.Sum256([]byte(token))
m.mu.Lock()
defer m.mu.Unlock()
m.pruneLocked(now)
record, ok := m.sessions[hash]
return ok && record.ExpiresAt.After(now)
}
func (m *SessionManager) RevokeAll() {
if m == nil {
return
}
m.mu.Lock()
clear(m.sessions)
m.mu.Unlock()
}
func (m *SessionManager) reserveExchange(source string, now time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
m.pruneLocked(now)
if m.inFlight >= bootstrapExchangeInFlightLimit {
return ErrBootstrapBusy
}
attempts, ok := m.sources[source]
if !ok && len(m.sources) >= bootstrapSourceCapacity {
return ErrBootstrapRateLimited
}
if !ok || now.Sub(attempts.WindowStarted) >= bootstrapSourceAttemptWindow {
attempts = bootstrapSourceAttempts{WindowStarted: now}
}
if attempts.Count >= bootstrapSourceAttemptLimit {
return ErrBootstrapRateLimited
}
attempts.Count++
m.sources[source] = attempts
m.inFlight++
return nil
}
func (m *SessionManager) releaseExchange() {
m.mu.Lock()
if m.inFlight > 0 {
m.inFlight--
}
m.mu.Unlock()
}
func (m *SessionManager) pruneLocked(now time.Time) {
for hash, record := range m.sessions {
if !record.ExpiresAt.After(now) {
delete(m.sessions, hash)
}
}
for source, attempts := range m.sources {
if now.Sub(attempts.WindowStarted) >= bootstrapSourceAttemptWindow {
delete(m.sources, source)
}
}
}
func BootstrapSessionCookie(token string, expiresAt time.Time) *http.Cookie {
return &http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/api/v1/bootstrap",
Expires: expiresAt.UTC(),
MaxAge: max(1, int(time.Until(expiresAt).Seconds())),
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
}
func ClearBootstrapSessionCookie() *http.Cookie {
return &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/api/v1/bootstrap",
Expires: time.Unix(1, 0).UTC(),
MaxAge: -1,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
}
func normalizeBootstrapSource(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if host, _, err := net.SplitHostPort(value); err == nil {
value = host
}
ip := net.ParseIP(strings.Trim(value, "[]"))
if ip == nil || ip.IsUnspecified() {
return ""
}
return ip.String()
}
func secureRandomToken(size int) (string, error) {
if size < 16 {
return "", errors.New("安全令牌长度不足")
}
buffer := make([]byte, size)
if _, err := readRandom(buffer); err != nil {
return "", errors.New("生成安全令牌失败")
}
return encodeToken(buffer), nil
}