feat(account): throttle confirm-code sends per recipient

Add an in-memory SendLimiter enforcing a one-per-minute cooldown and a
five-per-rolling-hour cap per recipient address, checked before provisioning or
sending in RequestCode, RequestLoginCode and RequestLinkCode. It guards against
email bombing and protects the relay quota. The limiter is injected in main
(nil in tests, so the domain suite is unaffected); ErrTooManyRequests maps to
HTTP 429.
This commit is contained in:
Ilia Denisov
2026-07-03 03:02:49 +02:00
parent 3877b23894
commit 2207ac6132
7 changed files with 138 additions and 0 deletions
+3
View File
@@ -175,6 +175,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// so they are handed to the server (like the route groups) for the handlers.
mailer := newMailer(cfg.SMTP, logger)
emails := account.NewEmailService(accounts, mailer, cfg.PublicBaseURL)
// Throttle confirm-code sends per recipient: at most one per minute and five per
// rolling hour, guarding against email bombing and the relay's own quota.
emails.SetSendLimiter(account.NewSendLimiter(time.Minute, 5))
// Account linking & merge: the orchestrator over the account, merge and
// session layers. Wired to the /api/v1/user/link REST surface below.
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
+20
View File
@@ -55,6 +55,9 @@ var (
ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
// ErrCodeMismatch is returned when the submitted code does not match.
ErrCodeMismatch = errors.New("account: confirmation code does not match")
// ErrTooManyRequests is returned when confirm-code sends to an address are being
// requested too frequently (the resend cooldown or the rolling-hour cap).
ErrTooManyRequests = errors.New("account: too many code requests")
)
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a
@@ -67,6 +70,7 @@ type EmailService struct {
store *Store
mailer Mailer
baseURL string
limiter *SendLimiter
now func() time.Time
}
@@ -78,6 +82,16 @@ func NewEmailService(store *Store, mailer Mailer, baseURL string) *EmailService
return &EmailService{store: store, mailer: mailer, baseURL: baseURL, now: func() time.Time { return time.Now().UTC() }}
}
// SetSendLimiter installs a per-recipient send throttle. When unset (nil), sends are
// not throttled — production wires a limiter; tests leave it off.
func (s *EmailService) SetSendLimiter(l *SendLimiter) { s.limiter = l }
// allowSend reports whether a confirm-code send to email is permitted now, recording
// it when so. A nil limiter permits every send.
func (s *EmailService) allowSend(email string) bool {
return s.limiter == nil || s.limiter.Allow(email)
}
// issueCode generates a fresh confirm-code for (accountID, email), replaces any prior
// pending confirmation, and mails the branded code in locale; purpose selects the
// email wording. Only the SHA-256 hash of the code is stored.
@@ -115,6 +129,9 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
if err != nil {
return err
}
if !s.allowSend(addr) {
return ErrTooManyRequests
}
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
if err != nil {
return err
@@ -172,6 +189,9 @@ func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, l
if err != nil {
return uuid.UUID{}, err
}
if !s.allowSend(addr) {
return uuid.UUID{}, ErrTooManyRequests
}
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ, language)
if err != nil {
return uuid.UUID{}, err
+3
View File
@@ -26,6 +26,9 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
if err != nil {
return err
}
if !s.allowSend(addr) {
return ErrTooManyRequests
}
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
}
+66
View File
@@ -0,0 +1,66 @@
package account
import (
"sync"
"time"
)
// SendLimiter throttles confirm-code sends per recipient address: it enforces a
// minimum cooldown between two sends and a cap over a rolling hour. It guards against
// email bombing and protects the relay's own quota. State is in-memory (per process,
// reset on restart) and keyed by the normalised recipient address, which is adequate
// for the single-instance backend. Safe for concurrent use.
type SendLimiter struct {
mu sync.Mutex
cooldown time.Duration
perHour int
now func() time.Time
sends map[string][]time.Time
}
// NewSendLimiter returns a SendLimiter allowing at most one send per cooldown and at
// most perHour sends over any rolling hour, to the same recipient.
func NewSendLimiter(cooldown time.Duration, perHour int) *SendLimiter {
return &SendLimiter{
cooldown: cooldown,
perHour: perHour,
now: func() time.Time { return time.Now() },
sends: make(map[string][]time.Time),
}
}
// Allow reports whether a send to key is permitted now, recording the send when it is.
// It is denied when the last send was within the cooldown or the rolling-hour cap is
// already reached.
func (l *SendLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
cutoff := now.Add(-time.Hour)
kept := l.sends[key][:0]
for _, t := range l.sends[key] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if n := len(kept); n > 0 && now.Sub(kept[n-1]) < l.cooldown {
l.set(key, kept)
return false
}
if len(kept) >= l.perHour {
l.set(key, kept)
return false
}
l.set(key, append(kept, now))
return true
}
// set stores the retained send times for key, dropping the entry entirely once empty
// so the map stays bounded to recipients active within the last hour.
func (l *SendLimiter) set(key string, times []time.Time) {
if len(times) == 0 {
delete(l.sends, key)
return
}
l.sends[key] = times
}
@@ -0,0 +1,43 @@
package account
import (
"testing"
"time"
)
// TestSendLimiter checks the per-recipient cooldown and the rolling-hour cap, and
// that recipients are throttled independently.
func TestSendLimiter(t *testing.T) {
base := time.Now()
now := base
l := NewSendLimiter(time.Minute, 3)
l.now = func() time.Time { return now }
if !l.Allow("a") {
t.Fatal("send 1 should be allowed")
}
if l.Allow("a") {
t.Fatal("immediate resend must be blocked by the cooldown")
}
if !l.Allow("b") {
t.Fatal("a different recipient is independent")
}
now = base.Add(time.Minute)
if !l.Allow("a") {
t.Fatal("send 2 after the cooldown should be allowed")
}
now = base.Add(2 * time.Minute)
if !l.Allow("a") {
t.Fatal("send 3 should be allowed")
}
now = base.Add(3 * time.Minute)
if l.Allow("a") {
t.Fatal("send 4 within the hour must be blocked by the cap")
}
now = base.Add(time.Hour + time.Minute)
if !l.Allow("a") {
t.Fatal("after the rolling hour the cap resets")
}
}
+1
View File
@@ -30,6 +30,7 @@ func TestStatusForError(t *testing.T) {
"illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
"too many codes": {account.ErrTooManyRequests, http.StatusTooManyRequests, "too_many_requests"},
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
+2
View File
@@ -237,6 +237,8 @@ func statusForError(err error) (int, string) {
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts):
return http.StatusUnauthorized, "code_invalid"
case errors.Is(err, account.ErrTooManyRequests):
return http.StatusTooManyRequests, "too_many_requests"
case errors.Is(err, session.ErrNotFound):
return http.StatusUnauthorized, "session_invalid"
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),