Smail/backend/services/verification/rate_limit.go
smail f60d3b0880
Some checks are pending
Security Checks / security (push) Waiting to run
整理前后端与测试构建目录并完善配置和安全引导
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
2026-09-05 19:19:52 +08:00

250 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package verification
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net"
"net/netip"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Smail/models"
"xorm.io/xorm"
)
const (
RateLimitDimensionAccount = "account"
RateLimitDimensionEmail = "email"
RateLimitDimensionIP = "ip"
RateLimitDimensionUser = "user"
RateLimitDimensionPurpose = "purpose"
RateLimitDimensionTransaction = "transaction"
RateLimitDimensionEnvironment = "environment"
RateLimitDimensionCredential = "credential"
PersistentRateLimitStateRetention = 30 * 24 * time.Hour
)
var ErrRateLimitStoreUnavailable = errors.New("verification rate limit store unavailable")
type RateLimitRequest struct {
Account string
Email string
RemoteIP string
UserID int
Purpose Purpose
BrowserTransaction string
Environment string
CredentialVersion int
}
type rateLimitDimension struct {
typ string
value string
hourLimit int
dayLimit int
}
// PersistentRateLimiter 使用数据库事务保存限流计数,数据库中只保存哈希后的维度键。
type PersistentRateLimiter struct {
engine *xorm.Engine
mu sync.Mutex
now func() time.Time
}
func NewPersistentRateLimiter(engine *xorm.Engine) (*PersistentRateLimiter, error) {
if engine == nil {
return nil, ErrRateLimitStoreUnavailable
}
return &PersistentRateLimiter{engine: engine, now: time.Now}, nil
}
func (r *PersistentRateLimiter) Allow(ctx context.Context, request RateLimitRequest, now time.Time) (bool, error) {
if r == nil || r.engine == nil {
return false, ErrRateLimitStoreUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if now.IsZero() {
now = r.now()
}
dimensions := buildRateLimitDimensions(request)
if len(dimensions) == 0 {
return false, ErrRateLimitStoreUnavailable
}
// SQLite 使用单连接和数据库锁,MySQL/PostgreSQL 通过行锁保证跨进程原子更新。
r.mu.Lock()
defer r.mu.Unlock()
session := r.engine.NewSession().Context(ctx)
if err := session.Begin(); err != nil {
return false, fmt.Errorf("begin verification rate limit transaction: %w", err)
}
rollback := func(err error) (bool, error) {
_ = session.Rollback()
return false, err
}
rows := make([]models.VerificationRateLimit, 0, len(dimensions))
for _, dimension := range dimensions {
key := hashRateLimitKey(dimension.typ, dimension.value)
if err := insertRateLimitRow(session, key, dimension.typ, now); err != nil {
return rollback(fmt.Errorf("initialize verification rate limit: %w", err))
}
var row models.VerificationRateLimit
query := session.Where("dimension_key = ?", key)
if r.engine.DriverName() != "sqlite3" && r.engine.DriverName() != "sqlite" {
query = query.ForUpdate()
}
has, err := query.Get(&row)
if err != nil {
return rollback(fmt.Errorf("load verification rate limit: %w", err))
}
if !has {
return rollback(ErrRateLimitStoreUnavailable)
}
if !rateLimitRowAllowed(row, now, dimension.hourLimit, dimension.dayLimit) {
return rollback(nil)
}
rows = append(rows, row)
}
for i, dimension := range dimensions {
row := rows[i]
updated := now.UTC()
if row.HourStartedAt == nil || now.Sub(*row.HourStartedAt) >= issueWindow {
row.HourStartedAt = timePtr(now)
row.HourCount = 0
}
if row.DayStartedAt == nil || now.Sub(*row.DayStartedAt) >= issueDaily {
row.DayStartedAt = timePtr(now)
row.DayCount = 0
}
row.HourCount++
row.DayCount++
row.LastIssuedAt = timePtr(now)
row.ExpiresAt = updated.Add(PersistentRateLimitStateRetention)
row.UpdatedAt = updated
if _, err := session.ID(row.ID).Cols("hour_started_at", "hour_count", "day_started_at", "day_count", "last_issued_at", "expires_at", "updated_at").Update(&row); err != nil {
return rollback(fmt.Errorf("update verification rate limit %s: %w", dimension.typ, err))
}
}
if err := session.Commit(); err != nil {
return false, fmt.Errorf("commit verification rate limit transaction: %w", err)
}
return true, nil
}
func (r *PersistentRateLimiter) Cleanup(ctx context.Context, now time.Time) (int, error) {
if r == nil || r.engine == nil {
return 0, ErrRateLimitStoreUnavailable
}
if now.IsZero() {
now = r.now()
}
cutoff := now.UTC()
result, err := r.engine.Context(ctx).Where("expires_at < ?", cutoff).Delete(&models.VerificationRateLimit{})
if err != nil {
return 0, fmt.Errorf("cleanup verification rate limits: %w", err)
}
return int(result), nil
}
func buildRateLimitDimensions(request RateLimitRequest) []rateLimitDimension {
account := strings.ToLower(strings.TrimSpace(request.Account))
email := strings.ToLower(strings.TrimSpace(request.Email))
remoteIP := normalizeRateLimitIP(request.RemoteIP)
purpose := strings.ToLower(strings.TrimSpace(string(request.Purpose)))
environment := strings.TrimSpace(request.Environment)
if environment == "" {
environment = "default"
}
scope := func(value string) string {
return environment + "\x00" + value
}
dimensions := make([]rateLimitDimension, 0, 8)
appendDimension := func(typ, value string, hourLimit, dayLimit int) {
if strings.TrimSpace(value) != "" {
dimensions = append(dimensions, rateLimitDimension{typ: typ, value: value, hourLimit: hourLimit, dayLimit: dayLimit})
}
}
appendDimension(RateLimitDimensionAccount, scope(account), maxPerHour, maxPerDay)
appendDimension(RateLimitDimensionEmail, scope(email), maxPerHour, maxPerDay)
appendDimension(RateLimitDimensionIP, scope(remoteIP), maxPerIPHour, maxPerIPDay)
if request.UserID > 0 {
appendDimension(RateLimitDimensionUser, scope(fmt.Sprintf("%d", request.UserID)), maxPerHour, maxPerDay)
}
if purpose != "" && account != "" {
appendDimension(RateLimitDimensionPurpose, scope(purpose+"\x00"+account), maxPerHour, maxPerDay)
}
if transaction := strings.TrimSpace(request.BrowserTransaction); transaction != "" {
appendDimension(RateLimitDimensionTransaction, scope(transaction), maxPerHour, maxPerDay)
}
if request.CredentialVersion > 0 && account != "" {
appendDimension(RateLimitDimensionCredential, scope(fmt.Sprintf("%d\x00%s", request.CredentialVersion, account)), maxPerHour, maxPerDay)
}
return dimensions
}
func hashRateLimitKey(typ, value string) string {
hash := sha256.New()
_, _ = hash.Write([]byte("smail:verification-rate-limit:v1:"))
_, _ = hash.Write([]byte(typ))
_, _ = hash.Write([]byte{0})
_, _ = hash.Write([]byte(value))
return hex.EncodeToString(hash.Sum(nil))
}
func rateLimitRowAllowed(row models.VerificationRateLimit, now time.Time, hourLimit, dayLimit int) bool {
if row.LastIssuedAt != nil && now.Sub(*row.LastIssuedAt) < issueInterval {
return false
}
hourCount := row.HourCount
if row.HourStartedAt == nil || now.Sub(*row.HourStartedAt) >= issueWindow {
hourCount = 0
}
dayCount := row.DayCount
if row.DayStartedAt == nil || now.Sub(*row.DayStartedAt) >= issueDaily {
dayCount = 0
}
return hourCount < hourLimit && dayCount < dayLimit
}
func insertRateLimitRow(session *xorm.Session, key, typ string, now time.Time) error {
var query string
switch session.Engine().DriverName() {
case "mysql":
query = "INSERT IGNORE INTO verification_rate_limit (dimension_key, dimension_type, expires_at, updated_at) VALUES (?, ?, ?, ?)"
case "postgres":
query = "INSERT INTO verification_rate_limit (dimension_key, dimension_type, expires_at, updated_at) VALUES ($1, $2, $3, $4) ON CONFLICT (dimension_key) DO NOTHING"
default:
query = "INSERT OR IGNORE INTO verification_rate_limit (dimension_key, dimension_type, expires_at, updated_at) VALUES (?, ?, ?, ?)"
}
updated := now.UTC()
_, err := session.Exec(query, key, typ, updated.Add(PersistentRateLimitStateRetention), updated)
return err
}
func normalizeRateLimitIP(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if host, _, err := net.SplitHostPort(value); err == nil {
value = host
} else {
value = strings.Trim(value, "[]")
}
if address, err := netip.ParseAddr(value); err == nil {
return address.Unmap().String()
}
return strings.ToLower(value)
}
func timePtr(value time.Time) *time.Time {
value = value.UTC()
return &value
}