owpengram-server/internal/rpc/rate_limit.go

136 lines
4.3 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 rpc
import (
"context"
"crypto/sha256"
"encoding/hex"
"strconv"
"time"
"go.uber.org/zap"
"telesrv/internal/domain"
)
const sendRateLimitKeyPrefix = "messages:send:"
const (
authCodePhoneRateLimitKeyPrefix = "auth:code:phone-sha256:"
authCodeAuthKeyRateLimitKeyPrefix = "auth:code:raw-auth-key:"
defaultAuthCodeRateWindow = 10 * time.Minute
)
const (
channelDifferenceRateLimitKeyPrefix = "updates:channeldifference:"
peerDialogsRateLimitKeyPrefix = "messages:peerdialogs:"
defaultCatchupRateWindow = time.Minute
)
// checkCatchupRateLimit 对 difference 类 catch-up RPCgetChannelDifference / getPeerDialogs
// 每用户频率限速,超限返回 FLOOD_WAIT设计 Phase 2 / §10.3。keyPrefix 区分两类(各自独立计数),
// 共用 cfg.CatchupRateLimit/Window 阈值。Limiter 未装配或阈值 <=0 时不限速(行为不变)。
func (r *Router) checkCatchupRateLimit(ctx context.Context, userID int64, keyPrefix string) error {
if r.deps.Limiter == nil || userID == 0 {
return nil
}
limit := r.cfg.CatchupRateLimit
if limit <= 0 {
return nil
}
window := r.cfg.CatchupRateWindow
if window <= 0 {
window = defaultCatchupRateWindow
}
allowed, retryAfter, err := r.deps.Limiter.AllowN(ctx, keyPrefix+strconv.FormatInt(userID, 10), 1, limit, window)
if err != nil {
return internalErr()
}
if allowed {
return nil
}
r.log.Debug("catch-up rpc rate limited (flood wait)",
zap.Int64("user_id", userID), zap.String("kind", keyPrefix), zap.Int("retry_after", retryAfter))
return floodWaitErr(retryAfter)
}
func (r *Router) checkSendRateLimit(ctx context.Context, userID int64, cost int) error {
if r.deps.Limiter == nil || userID == 0 || cost <= 0 {
return nil
}
limit := r.cfg.SendRateLimit
if limit <= 0 {
return nil
}
window := r.cfg.SendRateWindow
if window <= 0 {
window = sendMessageRateWindow
}
allowed, retryAfter, err := r.deps.Limiter.AllowN(ctx, sendRateLimitKeyPrefix+strconv.FormatInt(userID, 10), cost, limit, window)
if err != nil {
r.log.Warn("message send rate limiter failed",
append(r.contextLogFields(ctx),
zap.Error(err),
zap.Int("cost", cost),
zap.Int("limit", limit),
zap.Duration("window", window),
)...)
return internalErr()
}
if allowed {
return nil
}
r.metrics().MessageRateLimited(retryAfter)
return floodWaitErr(retryAfter)
}
// checkAuthCodeRateLimit protects the unauthenticated code-issuance path before
// any account lookup or durable 777000 write. Existing and unknown phone numbers
// therefore consume identical budgets and cannot be distinguished through the
// limiter. Plaintext phone numbers are never used as limiter keys or log fields.
func (r *Router) checkAuthCodeRateLimit(ctx context.Context, phone string) error {
if r.deps.Limiter == nil {
return nil
}
normalizedPhone := domain.NormalizePhone(phone)
if !domain.ValidPhone(normalizedPhone) {
return phoneNumberInvalidErr()
}
window := r.cfg.AuthCodeRateWindow
if window <= 0 {
window = defaultAuthCodeRateWindow
}
// Check the connection/auth-key budget first. If that dimension is already
// blocked, changing phone strings cannot create one phone-digest Redis key
// per attempt and bypass the intended cardinality bound.
if limit := r.cfg.AuthCodeAuthKeyRateLimit; limit > 0 {
if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok && rawAuthKeyID != ([8]byte{}) {
if err := r.checkAuthCodeRateLimitKey(ctx, authCodeAuthKeyRateLimitKeyPrefix+hex.EncodeToString(rawAuthKeyID[:]), limit, window, "raw_auth_key"); err != nil {
return err
}
}
}
if limit := r.cfg.AuthCodePhoneRateLimit; limit > 0 {
digest := sha256.Sum256([]byte(normalizedPhone))
if err := r.checkAuthCodeRateLimitKey(ctx, authCodePhoneRateLimitKeyPrefix+hex.EncodeToString(digest[:]), limit, window, "phone_digest"); err != nil {
return err
}
}
return nil
}
func (r *Router) checkAuthCodeRateLimitKey(ctx context.Context, key string, limit int, window time.Duration, dimension string) error {
allowed, retryAfter, err := r.deps.Limiter.AllowN(ctx, key, 1, limit, window)
if err != nil {
return internalErr()
}
if allowed {
return nil
}
if retryAfter <= 0 {
retryAfter = 1
}
r.log.Debug("auth code issuance rate limited",
zap.String("dimension", dimension),
zap.Int("retry_after", retryAfter))
return floodWaitErr(retryAfter)
}