fix(auth): 公开登录门按来源限速并设全站发信上限,冷却表定期清理
This commit is contained in:
26 files changed
+1141
-64
No files matched your search
+70
-18
@@ -158,6 +158,16 @@ type API struct {
|
||||
// Consumed by handleHasJoined (handlers_hasjoined.go).
|
||||
AuthSources []AuthSource
|
||||
|
||||
// AuthDoorLimit bounds how often one client address may call the public
|
||||
// pre-session auth doors (ratelimit.go). MailLimit bounds all mail the API
|
||||
// sends, install-wide. Zero values disable them; cmd/felis wires both.
|
||||
AuthDoorLimit RateLimit
|
||||
MailLimit RateLimit
|
||||
// ClientIPHeader names the header the install's edge writes the client
|
||||
// address into (CF-Connecting-IP behind the Cloudflare tunnel,
|
||||
// X-Forwarded-For behind an operator proxy). Empty means the TCP peer.
|
||||
ClientIPHeader string
|
||||
|
||||
// Now is the clock, injectable for tests. Defaults to time.Now.
|
||||
Now func() time.Time
|
||||
|
||||
@@ -172,6 +182,11 @@ type API struct {
|
||||
|
||||
streamCapOnce sync.Once
|
||||
streamCap *streamLimiter
|
||||
|
||||
authDoorOnce sync.Once
|
||||
authDoorBuckets *bucketSet
|
||||
mailOnce sync.Once
|
||||
mailBuckets *bucketSet
|
||||
}
|
||||
|
||||
// panelURL returns the public player-console origin ("https://console.<root>"),
|
||||
@@ -286,6 +301,11 @@ type apiRoute struct {
|
||||
// whose EmailVerified is false is restricted to these routes only.
|
||||
SetupAllowed bool
|
||||
|
||||
// AuthDoor marks a public pre-session auth door: it is rate limited per
|
||||
// client address (throttleAuthDoor). The op-login status poll is left off,
|
||||
// since the browser calls it every few seconds while it waits.
|
||||
AuthDoor bool
|
||||
|
||||
h http.HandlerFunc
|
||||
}
|
||||
|
||||
@@ -381,21 +401,21 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
// counter-slice to the anti-enumeration doors — the ONE sanctioned place existence
|
||||
// is disclosed — but it never reveals staffness (methods computed with no role
|
||||
// branch, so a staff and a player address in the same state are indistinguishable).
|
||||
{Method: "POST", Pattern: "/api/v1/auth/options", Public: true, h: a.handleAuthOptions},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/setup/redeem", Public: true, h: a.handleSetupRedeem},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/options", Public: true, AuthDoor: true, h: a.handleAuthOptions},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/setup/redeem", Public: true, AuthDoor: true, h: a.handleSetupRedeem},
|
||||
{Method: "GET", Pattern: "/api/v1/auth/setup/status", SetupAllowed: true, h: a.handleSetupStatus},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/begin", Public: true, h: a.handlePasskeyLoginBegin},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/finish", Public: true, h: a.handlePasskeyLoginFinish},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/begin", Public: true, AuthDoor: true, h: a.handlePasskeyLoginBegin},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/finish", Public: true, AuthDoor: true, h: a.handlePasskeyLoginFinish},
|
||||
// Discoverable ("usernameless") passkey login (task #40): the from-zero sibling of the
|
||||
// email-first pair above — no identifier typed, the account is resolved from the
|
||||
// userHandle inside the signed assertion (handlers_passkey_discoverable.go).
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/discoverable/begin", Public: true, h: a.handlePasskeyLoginDiscoverableBegin},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/discoverable/finish", Public: true, h: a.handlePasskeyLoginDiscoverableFinish},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/email/start", Public: true, h: a.handleLoginEmailStart},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/email/verify", Public: true, h: a.handleLoginEmailVerify},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/op-login/start", Public: true, h: a.handleOpLoginStart},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/discoverable/begin", Public: true, AuthDoor: true, h: a.handlePasskeyLoginDiscoverableBegin},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/discoverable/finish", Public: true, AuthDoor: true, h: a.handlePasskeyLoginDiscoverableFinish},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/email/start", Public: true, AuthDoor: true, h: a.handleLoginEmailStart},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/email/verify", Public: true, AuthDoor: true, h: a.handleLoginEmailVerify},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/op-login/start", Public: true, AuthDoor: true, h: a.handleOpLoginStart},
|
||||
{Method: "GET", Pattern: "/api/v1/auth/op-login/status/{id}", Public: true, h: a.handleOpLoginStatus},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/op-login/finish", Public: true, h: a.handleOpLoginFinish},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/op-login/finish", Public: true, AuthDoor: true, h: a.handleOpLoginFinish},
|
||||
// Player-console bootstrap (console-tier access model): the account-less
|
||||
// player's door into console.<root_domain>. Public — like login there is no prior
|
||||
// principal — and session-minting, but the artifact it consumes is a one-time
|
||||
@@ -403,7 +423,7 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
// possession already proves a Minecraft identity. A code whose UUID belongs to
|
||||
// staff is refused (403) so this never yields an admin session; op.console stays
|
||||
// behind Zero Trust (handlers_onboard.go).
|
||||
{Method: "POST", Pattern: "/api/v1/auth/bind", Public: true, h: a.handleBindRedeem},
|
||||
{Method: "POST", Pattern: "/api/v1/auth/bind", Public: true, AuthDoor: true, h: a.handleBindRedeem},
|
||||
|
||||
// App-auth tier: operations on your own servers (spec §14).
|
||||
{Method: "POST", Pattern: "/api/v1/servers/{name}/wake", h: a.handleWake},
|
||||
@@ -614,7 +634,11 @@ func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler
|
||||
for _, rt := range routes {
|
||||
pattern := rt.Method + " " + rt.Pattern
|
||||
if rt.Public {
|
||||
mux.HandleFunc(pattern, rt.h)
|
||||
h := rt.h
|
||||
if rt.AuthDoor {
|
||||
h = a.throttleAuthDoor(h)
|
||||
}
|
||||
mux.HandleFunc(pattern, h)
|
||||
continue
|
||||
}
|
||||
h := rt.h
|
||||
@@ -727,10 +751,35 @@ func principalFromContext(ctx context.Context) *Principal {
|
||||
// reserve/release pair closes the intra-replica concurrent burst (the bug fixed in
|
||||
// #35); cross-replica bounding would need a shared store (out of scope for the
|
||||
// single-replica demo).
|
||||
//
|
||||
// Entries older than the longest window the limiter has been asked about can
|
||||
// no longer block anything, so checks sweep them out (at most once per
|
||||
// bucketSweepEvery). Without that, every distinct address typed into a public
|
||||
// door, whose neutral branch keeps its reservation, stayed in the map for the
|
||||
// life of the process.
|
||||
type cooldownLimiter struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
last map[string]time.Time
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
last map[string]time.Time
|
||||
maxWindow time.Duration
|
||||
swept time.Time
|
||||
}
|
||||
|
||||
// noteWindow widens the retention to window and sweeps stale entries when due.
|
||||
// The caller holds mu.
|
||||
func (c *cooldownLimiter) noteWindow(window time.Duration, now time.Time) {
|
||||
if window > c.maxWindow {
|
||||
c.maxWindow = window
|
||||
}
|
||||
if c.maxWindow <= 0 || now.Sub(c.swept) < bucketSweepEvery {
|
||||
return
|
||||
}
|
||||
c.swept = now
|
||||
for k, t := range c.last {
|
||||
if now.Sub(t) >= c.maxWindow {
|
||||
delete(c.last, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// allowed reports whether name may wake now WITHOUT recording the attempt. A
|
||||
@@ -745,7 +794,9 @@ func (c *cooldownLimiter) allowed(name string, window time.Duration) bool {
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if last, ok := c.last[name]; ok && c.now().Sub(last) < window {
|
||||
now := c.now()
|
||||
c.noteWindow(window, now)
|
||||
if last, ok := c.last[name]; ok && now.Sub(last) < window {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -778,10 +829,11 @@ func (c *cooldownLimiter) reserve(name string, window time.Duration) (time.Time,
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if last, ok := c.last[name]; ok && c.now().Sub(last) < window {
|
||||
t := c.now()
|
||||
c.noteWindow(window, t)
|
||||
if last, ok := c.last[name]; ok && t.Sub(last) < window {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t := c.now()
|
||||
c.last[name] = t
|
||||
return t, true
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -130,10 +131,19 @@ type apiError struct {
|
||||
status int
|
||||
code string
|
||||
msg string
|
||||
// wait, when positive, is sent as Retry-After (whole seconds, rounded up).
|
||||
wait time.Duration
|
||||
}
|
||||
|
||||
func (e *apiError) Error() string { return e.msg }
|
||||
|
||||
// retryAfter returns a copy of e that tells the client when to retry.
|
||||
func (e *apiError) retryAfter(d time.Duration) *apiError {
|
||||
c := *e
|
||||
c.wait = d
|
||||
return &c
|
||||
}
|
||||
|
||||
// newError builds an apiError with a formatted message.
|
||||
func newError(status int, code, format string, a ...any) *apiError {
|
||||
return &apiError{status: status, code: code, msg: fmt.Sprintf(format, a...)}
|
||||
@@ -176,6 +186,9 @@ func writeError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
r.Method, r.URL.Path, requestIDFromContext(r.Context()), err)
|
||||
ae = newError(http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
if ae.wait > 0 {
|
||||
w.Header().Set("Retry-After", strconv.FormatInt(int64((ae.wait+time.Second-1)/time.Second), 10))
|
||||
}
|
||||
body := map[string]any{
|
||||
"error": map[string]string{
|
||||
"code": ae.code,
|
||||
|
||||
@@ -24,12 +24,9 @@ import (
|
||||
// this separator).
|
||||
// - No principal. The throttle cannot key off a user id (there is none yet); it
|
||||
// keys off the typed recipient address, the same anti-bomb dimension the onboard
|
||||
// start uses. Per-source (client-IP) aggregate limiting is deliberately NOT done
|
||||
// here: cooldownLimiter is a one-per-window primitive, so keying it on client IP
|
||||
// would false-positive on shared egress (CGNAT / office NAT), and behind
|
||||
// Cloudflare RemoteAddr is the proxy anyway. The only real harm — bombing one
|
||||
// mailbox — is already bounded per recipient; volumetric per-source limiting
|
||||
// belongs at the edge.
|
||||
// start uses. Volume from one client is bounded separately by the per-address
|
||||
// token bucket every public auth door sits behind (throttleAuthDoor), and total
|
||||
// mail by the install-wide mail budget (ratelimit.go).
|
||||
// - Refuse staff. Like handleBindRedeem this public door provably never mints a
|
||||
// session for an admin identity: op.console stays behind Zero Trust (and its own
|
||||
// in-game approval gate). The refusal happens only AFTER a valid code is
|
||||
@@ -84,6 +81,13 @@ func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The install-wide mail budget is checked before the address is resolved,
|
||||
// so while it is spent every address gets the same 429.
|
||||
if err := a.checkMailBudget(); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Atomically reserve the per-recipient cooldown BEFORE any work, so a burst of
|
||||
// truly concurrent starts yields exactly one winner and each admitted send is one
|
||||
// real, non-idempotent email. The key is namespaced apart from the onboard door's
|
||||
|
||||
@@ -16,11 +16,10 @@ import (
|
||||
// address has an account precisely because THIS endpoint is the one sanctioned place
|
||||
// existence is revealed. An empty methods array means "no (verified) account". That
|
||||
// makes it a mass-enumeration surface by design — an accepted product decision, the
|
||||
// same one the email door's header records. It is bounded only at the edge: the
|
||||
// handler sends no mail and mutates nothing, so a per-recipient cooldown would merely
|
||||
// block a legitimate retry, and per-source (client-IP) limiting is the edge's job
|
||||
// (behind Cloudflare RemoteAddr is the proxy, and CGNAT would false-positive) — see
|
||||
// the handlers_auth_email.go header for the same reasoning.
|
||||
// same one the email door's header records. The handler sends no mail and mutates
|
||||
// nothing, so a per-recipient cooldown would merely block a legitimate retry; what
|
||||
// bounds enumeration is the per-client-address token bucket shared by every public
|
||||
// auth door (throttleAuthDoor in ratelimit.go).
|
||||
//
|
||||
// It never reveals STAFFNESS. Methods are computed by the SAME rule for every resolved
|
||||
// account — no role branch, no operator hint — so a staff email and a player email in
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/metrics"
|
||||
)
|
||||
|
||||
// Player email verification (spec §B2 onboarding). Forced web onboarding proves a
|
||||
@@ -250,12 +252,21 @@ func (a *API) handleEmailOTPVerify(w http.ResponseWriter, r *http.Request) {
|
||||
// deliverOTP hands the code to the configured Mailer, or — when none is wired (the
|
||||
// demo) — logs it server-side as a KNOWN-LIMITATION. The code is logged ONLY in the
|
||||
// no-mailer fallback and ONLY to the server log; it is never put in an HTTP response.
|
||||
//
|
||||
// Every real send spends one token of the install-wide mail budget (mailGate);
|
||||
// a spent budget is a 429 mail_rate_limited and nothing reaches the relay.
|
||||
func (a *API) deliverOTP(ctx context.Context, email, code string) error {
|
||||
if a.Mailer == nil {
|
||||
log.Printf("email-otp: no Mailer configured; code for %s is %s (KNOWN-LIMITATION: demo has no SMTP)", email, code)
|
||||
return nil
|
||||
}
|
||||
if ok, wait := a.mailGate().take(mailGateKey); !ok {
|
||||
metrics.MailTotal.WithLabelValues("otp", "throttled").Inc()
|
||||
log.Printf("api: OTP mail refused by the install-wide mail budget (request_id=%s)", requestIDFromContext(ctx))
|
||||
return errMailRateLimited(wait)
|
||||
}
|
||||
if err := a.Mailer.SendOTP(ctx, email, code); err != nil {
|
||||
metrics.MailTotal.WithLabelValues("otp", "failed").Inc()
|
||||
// Mapped here rather than at each of the four call sites, so every door that
|
||||
// mails a code answers the same way. A relay refusal is neither the caller's
|
||||
// fault nor a bug in Felis, and a bare 500 says neither — it reads as "the
|
||||
@@ -269,6 +280,7 @@ func (a *API) deliverOTP(ctx context.Context, email, code string) error {
|
||||
return newError(http.StatusBadGateway, "mail_undeliverable",
|
||||
"the mail relay refused this message; ask the server operator to check the SMTP settings")
|
||||
}
|
||||
metrics.MailTotal.WithLabelValues("otp", "sent").Inc()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,13 @@ func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The install-wide mail budget is checked before the address is resolved,
|
||||
// so while it is spent every address gets the same 429.
|
||||
if err := a.checkMailBudget(); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Per-recipient cooldown reserved BEFORE any work, identical to the console email
|
||||
// door: one winner per window, and the neutral (non-staff) branch keeps the
|
||||
// reservation too so probing an address is throttled exactly like a real send. The
|
||||
|
||||
@@ -21,10 +21,10 @@ import (
|
||||
//
|
||||
// Anti-abuse divergence from the email-first door: that door reserves a per-recipient cooldown
|
||||
// (a.otpLimiter) keyed on the typed email. A usernameless begin has no recipient OR principal to
|
||||
// key a fair per-caller limit on, so — matching the stance in handlers_auth_email.go (behind
|
||||
// Cloudflare RemoteAddr is the proxy; CGNAT false-positives) — volumetric per-source limiting is
|
||||
// left to the edge, and the server-side bound is a hard global cap on live challenges enforced
|
||||
// atomically in CreateDiscoverableChallenge (ErrTooManyDiscoverableChallenges → 429).
|
||||
// key a fair per-caller limit on, so one client is bounded by the per-address token bucket every
|
||||
// public auth door sits behind (throttleAuthDoor), and the table by a hard global cap on live
|
||||
// challenges enforced atomically in CreateDiscoverableChallenge (ErrTooManyDiscoverableChallenges
|
||||
// → 429).
|
||||
|
||||
// handlePasskeyLoginDiscoverableBegin starts a usernameless assertion ceremony (Public,
|
||||
// pre-session). It has no request body — the whole point is that the caller supplies no
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/metrics"
|
||||
@@ -35,14 +34,9 @@ var otpDoorName = map[string][2]string{
|
||||
|
||||
// writeOTPAccountLocked answers a signed-in door whose budget is spent.
|
||||
func writeOTPAccountLocked(w http.ResponseWriter, r *http.Request, until, now time.Time) {
|
||||
secs := int64(until.Sub(now).Round(time.Second) / time.Second)
|
||||
if secs < 1 {
|
||||
secs = 1
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.FormatInt(secs, 10))
|
||||
writeError(w, r, newError(http.StatusTooManyRequests, "otp_account_locked",
|
||||
"too many wrong codes on this account; email codes work again after %s",
|
||||
until.UTC().Format(time.RFC3339)))
|
||||
until.UTC().Format(time.RFC3339)).retryAfter(max(until.Sub(now), time.Second)))
|
||||
}
|
||||
|
||||
// noteOTPLock handles a redeem that met the account lock. Only the guess that
|
||||
@@ -83,10 +77,18 @@ func (a *API) noteOTPLock(r *http.Request, err error, userID, purpose string) {
|
||||
log.Printf("auth: no notice mailer; user %s was not told their %s is locked", userID, purpose)
|
||||
return
|
||||
}
|
||||
if ok, _ := a.mailGate().take(mailGateKey); !ok {
|
||||
metrics.MailTotal.WithLabelValues("notice", "throttled").Inc()
|
||||
log.Printf("auth: mail budget spent; user %s was not told their %s is locked", userID, purpose)
|
||||
return
|
||||
}
|
||||
subject, body := otpLockNotice(door, lock.Until)
|
||||
if err := sender.SendNotice(ctx, u.Email, subject, body); err != nil {
|
||||
metrics.MailTotal.WithLabelValues("notice", "failed").Inc()
|
||||
log.Printf("auth: otp lock notice to user %s failed: %v", userID, err)
|
||||
return
|
||||
}
|
||||
metrics.MailTotal.WithLabelValues("notice", "sent").Inc()
|
||||
}
|
||||
|
||||
// otpLockNotice renders the bilingual lock notice.
|
||||
|
||||
@@ -3,9 +3,12 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/metrics"
|
||||
)
|
||||
|
||||
// The per-code attempt cap resets on every resend; these pin the account-level
|
||||
@@ -181,3 +184,14 @@ func TestOTPLockNoticeNamesDoorAndTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The lockout alert sees a purpose only if metrics pre-creates its series.
|
||||
func TestOTPPurposesMatchMetricLabels(t *testing.T) {
|
||||
got := []string{otpPurposeOnboard, otpPurposeLogin, otpPurposeOpLogin, otpPurposeMigrate}
|
||||
want := slices.Clone(metrics.OTPPurposes)
|
||||
slices.Sort(got)
|
||||
slices.Sort(want)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("otp purposes %v, metrics.OTPPurposes %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1218,9 +1218,8 @@ func (p *PGRepo) ConsumePasskeyChallengeByUser(ctx context.Context, userID, purp
|
||||
// exist, a new begin is refused (ErrTooManyDiscoverableChallenges → 429). The cap is generous —
|
||||
// a login challenge lives only passkeyChallengeTTL (5 min) and each row is ~1 KB — so real
|
||||
// concurrency never approaches it, while an abusive begin-flood is bounded to a few MB instead
|
||||
// of growing without limit. Volumetric per-IP limiting is the edge's job (handlers_auth_email.go):
|
||||
// behind Cloudflare RemoteAddr is the proxy, and a usernameless door has no recipient to key a
|
||||
// fair per-caller limit on.
|
||||
// of growing without limit. One client's volume is bounded before it gets here, by the
|
||||
// per-address token bucket in front of every public auth door (ratelimit.go).
|
||||
const maxLiveDiscoverableChallenges = 4096
|
||||
|
||||
// CreateDiscoverableChallenge stashes a discoverable-login ceremony under an opaque handle,
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/metrics"
|
||||
)
|
||||
|
||||
// Volumetric limits for the public auth doors and for outbound mail.
|
||||
//
|
||||
// The per-recipient OTP cooldown (otpLimiter) stops one mailbox being bombed,
|
||||
// and the per-account wrong-code budget stops one account being guessed. Neither
|
||||
// bounds a caller who sprays many addresses or many accounts, so two more limits
|
||||
// sit in front of them:
|
||||
//
|
||||
// - authDoorGate: a token bucket per client address over every pre-session
|
||||
// auth door (options, login, op-login, bind, setup redeem). The client
|
||||
// address comes from the edge's header only when the install says which
|
||||
// header its edge writes (ClientIPHeader); with Cloudflare that header is
|
||||
// CF-Connecting-IP, which is trustworthy because the edge setup fences the
|
||||
// panel NodePort to loopback, so every request reaching the origin came
|
||||
// through cloudflared. IPv6 clients are bucketed per /64, the unit one
|
||||
// subscriber is handed.
|
||||
// - mailGate: one install-wide bucket over every mail the API sends (codes
|
||||
// and lock notices), so no flood can burn the SMTP relay's quota and get
|
||||
// the sending account suspended. The public doors check it before they
|
||||
// resolve the address, so a spent budget answers every address alike.
|
||||
|
||||
// RateLimit is a token bucket: Burst calls at once, refilled at PerMinute. The
|
||||
// zero value disables the limit.
|
||||
type RateLimit struct {
|
||||
Burst int
|
||||
PerMinute float64
|
||||
}
|
||||
|
||||
func (l RateLimit) enabled() bool { return l.Burst > 0 && l.PerMinute > 0 }
|
||||
|
||||
// bucketSweepEvery is how often idle buckets are dropped; bucketMaxKeys bounds
|
||||
// the map between sweeps. Past the bound, new keys share one overflow bucket,
|
||||
// so a spray of fresh source addresses throttles itself instead of growing the
|
||||
// map.
|
||||
const (
|
||||
bucketSweepEvery = time.Minute
|
||||
bucketMaxKeys = 50_000
|
||||
bucketOverflow = "\x00overflow"
|
||||
)
|
||||
|
||||
type tokenBucket struct {
|
||||
tokens float64
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// bucketSet is a set of token buckets keyed by caller. A missing key is a full
|
||||
// bucket, so a bucket that has refilled completely carries no information and
|
||||
// is dropped by the sweep; memory is bounded by the keys active in the last
|
||||
// refill period.
|
||||
type bucketSet struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
limit RateLimit
|
||||
buckets map[string]*tokenBucket
|
||||
swept time.Time
|
||||
maxKeys int
|
||||
}
|
||||
|
||||
func newBucketSet(limit RateLimit, now func() time.Time) *bucketSet {
|
||||
return &bucketSet{now: now, limit: limit, buckets: map[string]*tokenBucket{}, maxKeys: bucketMaxKeys}
|
||||
}
|
||||
|
||||
// refill brings b up to now. The caller holds mu.
|
||||
func (s *bucketSet) refill(b *tokenBucket, now time.Time) {
|
||||
if elapsed := now.Sub(b.at); elapsed > 0 {
|
||||
b.tokens = math.Min(float64(s.limit.Burst), b.tokens+elapsed.Minutes()*s.limit.PerMinute)
|
||||
}
|
||||
b.at = now
|
||||
}
|
||||
|
||||
// sweep drops full buckets at most once per bucketSweepEvery, or at once when
|
||||
// force is set. The caller holds mu.
|
||||
func (s *bucketSet) sweep(now time.Time, force bool) {
|
||||
if !force && now.Sub(s.swept) < bucketSweepEvery {
|
||||
return
|
||||
}
|
||||
s.swept = now
|
||||
for k, b := range s.buckets {
|
||||
s.refill(b, now)
|
||||
if b.tokens >= float64(s.limit.Burst) {
|
||||
delete(s.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bucket returns key's bucket, creating a full one. The caller holds mu.
|
||||
func (s *bucketSet) bucket(key string, now time.Time) *tokenBucket {
|
||||
s.sweep(now, false)
|
||||
if b, ok := s.buckets[key]; ok {
|
||||
s.refill(b, now)
|
||||
return b
|
||||
}
|
||||
if len(s.buckets) >= s.maxKeys {
|
||||
s.sweep(now, true)
|
||||
if len(s.buckets) >= s.maxKeys {
|
||||
key = bucketOverflow
|
||||
if b, ok := s.buckets[key]; ok {
|
||||
s.refill(b, now)
|
||||
return b
|
||||
}
|
||||
}
|
||||
}
|
||||
b := &tokenBucket{tokens: float64(s.limit.Burst), at: now}
|
||||
s.buckets[key] = b
|
||||
return b
|
||||
}
|
||||
|
||||
// take spends one token from key's bucket. When none is left it reports how
|
||||
// long until one is. A disabled limit always admits.
|
||||
func (s *bucketSet) take(key string) (bool, time.Duration) {
|
||||
if s == nil || !s.limit.enabled() {
|
||||
return true, 0
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := s.now()
|
||||
b := s.bucket(key, now)
|
||||
if b.tokens >= 1 {
|
||||
b.tokens--
|
||||
return true, 0
|
||||
}
|
||||
return false, s.wait(b)
|
||||
}
|
||||
|
||||
// peek reports whether key's bucket holds a token, without spending it.
|
||||
func (s *bucketSet) peek(key string) (bool, time.Duration) {
|
||||
if s == nil || !s.limit.enabled() {
|
||||
return true, 0
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := s.now()
|
||||
b, ok := s.buckets[key]
|
||||
if !ok {
|
||||
return true, 0
|
||||
}
|
||||
s.refill(b, now)
|
||||
if b.tokens >= 1 {
|
||||
return true, 0
|
||||
}
|
||||
return false, s.wait(b)
|
||||
}
|
||||
|
||||
// wait is how long until b holds one token. The caller holds mu.
|
||||
func (s *bucketSet) wait(b *tokenBucket) time.Duration {
|
||||
missing := 1 - b.tokens
|
||||
return time.Duration(math.Ceil(missing / s.limit.PerMinute * float64(time.Minute)))
|
||||
}
|
||||
|
||||
func (a *API) authDoorGate() *bucketSet {
|
||||
a.authDoorOnce.Do(func() { a.authDoorBuckets = newBucketSet(a.AuthDoorLimit, a.now) })
|
||||
return a.authDoorBuckets
|
||||
}
|
||||
|
||||
func (a *API) mailGate() *bucketSet {
|
||||
a.mailOnce.Do(func() { a.mailBuckets = newBucketSet(a.MailLimit, a.now) })
|
||||
return a.mailBuckets
|
||||
}
|
||||
|
||||
// mailGateKey is the single install-wide mail bucket.
|
||||
const mailGateKey = "mail"
|
||||
|
||||
// throttleAuthDoor applies the per-source bucket to one public auth door.
|
||||
func (a *API) throttleAuthDoor(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ok, wait := a.authDoorGate().take(sourceKey(a.clientIP(r)))
|
||||
if !ok {
|
||||
metrics.RateLimitedTotal.WithLabelValues("auth_door").Inc()
|
||||
writeError(w, r, newError(http.StatusTooManyRequests, "rate_limited",
|
||||
"too many sign-in requests from this network; try again shortly").retryAfter(wait))
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// errMailRateLimited answers a door whose mail the install-wide budget refuses.
|
||||
func errMailRateLimited(wait time.Duration) *apiError {
|
||||
return newError(http.StatusTooManyRequests, "mail_rate_limited",
|
||||
"this server is sending too much mail right now; try again shortly").retryAfter(wait)
|
||||
}
|
||||
|
||||
// checkMailBudget is the public doors' pre-resolution check: it refuses every
|
||||
// address alike while the budget is spent, so the refusal says nothing about
|
||||
// whether the address has an account.
|
||||
func (a *API) checkMailBudget() error {
|
||||
if a.Mailer == nil {
|
||||
return nil
|
||||
}
|
||||
if ok, wait := a.mailGate().peek(mailGateKey); !ok {
|
||||
metrics.MailTotal.WithLabelValues("otp", "throttled").Inc()
|
||||
return errMailRateLimited(wait)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clientIP is the caller's address: the edge's header when the install names
|
||||
// one and the request carries a parseable value, else the TCP peer. For
|
||||
// X-Forwarded-For the rightmost entry is used, the one the trusted proxy
|
||||
// appended itself.
|
||||
func (a *API) clientIP(r *http.Request) netip.Addr {
|
||||
if name := a.ClientIPHeader; name != "" {
|
||||
if v := r.Header.Get(name); v != "" {
|
||||
if strings.EqualFold(name, "X-Forwarded-For") {
|
||||
if i := strings.LastIndexByte(v, ','); i >= 0 {
|
||||
v = v[i+1:]
|
||||
}
|
||||
}
|
||||
if ip, err := netip.ParseAddr(strings.TrimSpace(v)); err == nil {
|
||||
return ip.Unmap()
|
||||
}
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
ip, _ := netip.ParseAddr(host)
|
||||
return ip.Unmap()
|
||||
}
|
||||
|
||||
// sourceKey buckets an address: IPv4 per host, IPv6 per /64. An unparseable
|
||||
// address shares one key.
|
||||
func sourceKey(ip netip.Addr) string {
|
||||
switch {
|
||||
case !ip.IsValid():
|
||||
return "unknown"
|
||||
case ip.Is4():
|
||||
return ip.String()
|
||||
default:
|
||||
p, _ := ip.Prefix(64)
|
||||
return p.String()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// These pin the two volumetric limits in front of the public sign-in doors:
|
||||
// the per-client-address bucket (keyed on the edge's visitor header only when
|
||||
// the install names it) and the install-wide mail budget, which must refuse
|
||||
// every address alike so it never becomes an existence oracle. They also pin
|
||||
// that neither the buckets nor the OTP cooldown map grow without bound.
|
||||
|
||||
func TestBucketSetBurstRefillAndWait(t *testing.T) {
|
||||
clock := time.Unix(1_700_000_000, 0)
|
||||
s := newBucketSet(RateLimit{Burst: 3, PerMinute: 6}, func() time.Time { return clock })
|
||||
for i := 0; i < 3; i++ {
|
||||
if ok, _ := s.take("a"); !ok {
|
||||
t.Fatalf("take %d refused inside the burst", i+1)
|
||||
}
|
||||
}
|
||||
ok, wait := s.take("a")
|
||||
if ok || wait != 10*time.Second {
|
||||
t.Fatalf("4th take = %v wait %v, want refused with 10s wait (6/min)", ok, wait)
|
||||
}
|
||||
if ok, _ := s.take("b"); !ok {
|
||||
t.Fatal("another key shares a's bucket")
|
||||
}
|
||||
if ok, _ := s.peek("a"); ok {
|
||||
t.Fatal("peek admitted an empty bucket")
|
||||
}
|
||||
clock = clock.Add(10 * time.Second)
|
||||
if ok, _ := s.peek("a"); !ok {
|
||||
t.Fatal("peek refused after one token refilled")
|
||||
}
|
||||
if ok, _ := s.take("a"); !ok {
|
||||
t.Fatal("take refused after one token refilled (peek must not spend it)")
|
||||
}
|
||||
if ok, _ := s.take("a"); ok {
|
||||
t.Fatal("a second token appeared from nowhere")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketSetDropsIdleKeysAndCapsTheMap(t *testing.T) {
|
||||
clock := time.Unix(1_700_000_000, 0)
|
||||
s := newBucketSet(RateLimit{Burst: 2, PerMinute: 60}, func() time.Time { return clock })
|
||||
s.maxKeys = 100
|
||||
for i := 0; i < 100; i++ {
|
||||
s.take(fmt.Sprintf("10.0.0.%d", i))
|
||||
}
|
||||
// At the cap with every bucket still draining: fresh keys share the
|
||||
// overflow bucket instead of growing the map.
|
||||
s.take("fresh-1")
|
||||
s.take("fresh-2")
|
||||
if ok, _ := s.take("fresh-3"); ok {
|
||||
t.Fatal("overflow bucket admitted past its burst")
|
||||
}
|
||||
if n := len(s.buckets); n != 101 {
|
||||
t.Fatalf("map holds %d buckets, want 100 + overflow", n)
|
||||
}
|
||||
// Once they refill, the sweep drops them all.
|
||||
clock = clock.Add(2 * bucketSweepEvery)
|
||||
s.take("later")
|
||||
if n := len(s.buckets); n != 1 {
|
||||
t.Fatalf("after refill the map holds %d buckets, want only the new one", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledLimitAdmitsEverything(t *testing.T) {
|
||||
var nilSet *bucketSet
|
||||
if ok, _ := nilSet.take("x"); !ok {
|
||||
t.Fatal("nil set refused")
|
||||
}
|
||||
s := newBucketSet(RateLimit{}, time.Now)
|
||||
for i := 0; i < 1000; i++ {
|
||||
if ok, _ := s.take("x"); !ok {
|
||||
t.Fatal("zero limit refused")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPTrustsOnlyTheNamedHeader(t *testing.T) {
|
||||
req := func(remote string, h map[string]string) *http.Request {
|
||||
r := httptest.NewRequest("POST", "/", nil)
|
||||
r.RemoteAddr = remote
|
||||
for k, v := range h {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
return r
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
header string
|
||||
r *http.Request
|
||||
want string
|
||||
}{
|
||||
{"no header configured ignores CF-Connecting-IP", "", req("10.42.0.1:5000", map[string]string{"CF-Connecting-IP": "203.0.113.9"}), "10.42.0.1"},
|
||||
{"cloudflare header", "CF-Connecting-IP", req("10.42.0.1:5000", map[string]string{"CF-Connecting-IP": "203.0.113.9"}), "203.0.113.9"},
|
||||
{"cloudflare header absent falls back to peer", "CF-Connecting-IP", req("10.42.0.1:5000", nil), "10.42.0.1"},
|
||||
{"garbage header falls back to peer", "CF-Connecting-IP", req("10.42.0.1:5000", map[string]string{"CF-Connecting-IP": "not-an-ip"}), "10.42.0.1"},
|
||||
{"X-Forwarded-For takes the proxy-appended rightmost hop", "X-Forwarded-For", req("10.42.0.1:5000", map[string]string{"X-Forwarded-For": "1.1.1.1, 198.51.100.7"}), "198.51.100.7"},
|
||||
{"v4-mapped v6 is unmapped", "CF-Connecting-IP", req("10.42.0.1:5000", map[string]string{"CF-Connecting-IP": "::ffff:203.0.113.9"}), "203.0.113.9"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
a := &API{ClientIPHeader: tc.header}
|
||||
if got := a.clientIP(tc.r).String(); got != tc.want {
|
||||
t.Fatalf("clientIP = %s, want %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceKeyGroupsIPv6By64(t *testing.T) {
|
||||
a := sourceKey(netip.MustParseAddr("2001:db8:1:2::1"))
|
||||
b := sourceKey(netip.MustParseAddr("2001:db8:1:2:ffff::9"))
|
||||
c := sourceKey(netip.MustParseAddr("2001:db8:1:3::1"))
|
||||
if a != b || a == c {
|
||||
t.Fatalf("keys %q %q %q: want one per /64", a, b, c)
|
||||
}
|
||||
if k := sourceKey(netip.MustParseAddr("203.0.113.9")); k != "203.0.113.9" {
|
||||
t.Fatalf("v4 key = %q", k)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthDoorsThrottlePerClientAddress(t *testing.T) {
|
||||
api, _, _ := seedLoginEmailAPI(t)
|
||||
api.AuthDoorLimit = RateLimit{Burst: 3, PerMinute: 3}
|
||||
api.ClientIPHeader = "CF-Connecting-IP"
|
||||
eh := api.ExternalHandler()
|
||||
from := func(ip string) map[string]string {
|
||||
return map[string]string{"Content-Type": "application/json", "CF-Connecting-IP": ip}
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if w := do(eh, "POST", "/api/v1/auth/options", `{"email":"[email protected]"}`, from("203.0.113.9")); w.Code != http.StatusOK {
|
||||
t.Fatalf("call %d = %d (%s)", i+1, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
// The bucket spans every door: a different door from the same address is
|
||||
// refused too.
|
||||
w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"000000"}`, from("203.0.113.9"))
|
||||
if c, _ := errEnvelope(t, w); w.Code != http.StatusTooManyRequests || c != "rate_limited" {
|
||||
t.Fatalf("4th call = %d %s, want 429 rate_limited", w.Code, c)
|
||||
}
|
||||
if ra := w.Header().Get("Retry-After"); ra != "20" {
|
||||
t.Fatalf("Retry-After = %q, want 20 (3/min)", ra)
|
||||
}
|
||||
if w := do(eh, "POST", "/api/v1/auth/options", `{"email":"[email protected]"}`, from("198.51.100.7")); w.Code != http.StatusOK {
|
||||
t.Fatalf("another address was throttled: %d", w.Code)
|
||||
}
|
||||
// The op-login poll and logout are not doors: a waiting browser polls.
|
||||
for i := 0; i < 5; i++ {
|
||||
if w := do(eh, "GET", "/api/v1/auth/op-login/status/abc", "", from("203.0.113.9")); w.Code == http.StatusTooManyRequests {
|
||||
t.Fatal("op-login status poll was throttled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthDoorsIgnoreVisitorHeaderUnlessConfigured(t *testing.T) {
|
||||
api, _, _ := seedLoginEmailAPI(t)
|
||||
api.AuthDoorLimit = RateLimit{Burst: 2, PerMinute: 2}
|
||||
eh := api.ExternalHandler()
|
||||
// Without client_ip_header a forged CF-Connecting-IP must not mint fresh
|
||||
// buckets: every call below comes from httptest's one peer address.
|
||||
for i := 0; i < 2; i++ {
|
||||
do(eh, "POST", "/api/v1/auth/options", `{"email":"[email protected]"}`,
|
||||
map[string]string{"Content-Type": "application/json", "CF-Connecting-IP": fmt.Sprintf("203.0.113.%d", i)})
|
||||
}
|
||||
w := do(eh, "POST", "/api/v1/auth/options", `{"email":"[email protected]"}`,
|
||||
map[string]string{"Content-Type": "application/json", "CF-Connecting-IP": "203.0.113.200"})
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("forged visitor header escaped the limit: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailBudgetRefusesEveryAddressAlike(t *testing.T) {
|
||||
api, repo, mailer := seedLoginEmailAPI(t)
|
||||
repo.staff["second"] = &StaffUser{ID: "u2", Username: "second", Email: "[email protected]", Role: "user", EmailVerified: true}
|
||||
api.MailLimit = RateLimit{Burst: 1, PerMinute: 1}
|
||||
clock := time.Unix(1_700_000_000, 0)
|
||||
api.Now = func() time.Time { return clock }
|
||||
eh := api.ExternalHandler()
|
||||
start := func(email string) *httptest.ResponseRecorder {
|
||||
return do(eh, "POST", "/api/v1/auth/email/start", `{"email":"`+email+`"}`, jsonHeader)
|
||||
}
|
||||
|
||||
if w := start("[email protected]"); w.Code != http.StatusAccepted || mailer.calls != 1 {
|
||||
t.Fatalf("first send = %d, %d mails", w.Code, mailer.calls)
|
||||
}
|
||||
// Budget spent: a real account and an unknown address get the same 429,
|
||||
// and nothing reaches the relay.
|
||||
for _, email := range []string{"[email protected]", "[email protected]"} {
|
||||
w := start(email)
|
||||
if c, _ := errEnvelope(t, w); w.Code != http.StatusTooManyRequests || c != "mail_rate_limited" || w.Header().Get("Retry-After") == "" {
|
||||
t.Fatalf("%s while the budget is spent = %d %s (Retry-After %q)", email, w.Code, c, w.Header().Get("Retry-After"))
|
||||
}
|
||||
}
|
||||
if mailer.calls != 1 {
|
||||
t.Fatalf("a spent budget still mailed: %d sends", mailer.calls)
|
||||
}
|
||||
// The refused starts did not burn their recipients' cooldowns.
|
||||
clock = clock.Add(time.Minute)
|
||||
if w := start("[email protected]"); w.Code != http.StatusAccepted || mailer.calls != 2 {
|
||||
t.Fatalf("after refill = %d, %d mails", w.Code, mailer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedInDoorMailBudget(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.staff["player"] = &StaffUser{ID: "u1", Username: "player"}
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
api.External = staticExternal{p: &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}}
|
||||
mailer := &captureMailer{}
|
||||
api.Mailer = mailer
|
||||
// One mail per two minutes, so stepping past the per-principal resend
|
||||
// cooldown leaves the mail budget still empty.
|
||||
api.MailLimit = RateLimit{Burst: 1, PerMinute: 0.5}
|
||||
clock := time.Unix(1_700_000_000, 0)
|
||||
api.Now = func() time.Time { return clock }
|
||||
eh := api.ExternalHandler()
|
||||
if w := do(eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil); w.Code != http.StatusAccepted {
|
||||
t.Fatalf("first = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
clock = clock.Add(otpResendCooldown + time.Second)
|
||||
w := do(eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil)
|
||||
if c, _ := errEnvelope(t, w); w.Code != http.StatusTooManyRequests || c != "mail_rate_limited" {
|
||||
t.Fatalf("second = %d %s, want 429 mail_rate_limited", w.Code, c)
|
||||
}
|
||||
if mailer.calls != 1 {
|
||||
t.Fatalf("mails = %d", mailer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCooldownLimiterForgetsExpiredKeys(t *testing.T) {
|
||||
clock := time.Unix(1_700_000_000, 0)
|
||||
c := &cooldownLimiter{now: func() time.Time { return clock }, last: map[string]time.Time{}}
|
||||
for i := 0; i < 500; i++ {
|
||||
if _, ok := c.reserve(fmt.Sprintf("login:email:user%[email protected]", i), time.Minute); !ok {
|
||||
t.Fatalf("reserve %d refused", i)
|
||||
}
|
||||
}
|
||||
if _, ok := c.reserve("login:email:[email protected]", time.Minute); ok {
|
||||
t.Fatal("a live reservation was forgotten")
|
||||
}
|
||||
clock = clock.Add(time.Minute + bucketSweepEvery)
|
||||
c.reserve("login:email:[email protected]", time.Minute)
|
||||
if n := len(c.last); n != 1 {
|
||||
t.Fatalf("after every window ended the map holds %d keys, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailLimitCountsNotices(t *testing.T) {
|
||||
// The lock notice spends the same budget as codes; with none left it is
|
||||
// skipped rather than sent.
|
||||
api, _, _ := seedLoginEmailAPI(t)
|
||||
mailer := ¬iceMailer{}
|
||||
api.Mailer = mailer
|
||||
api.MailLimit = RateLimit{Burst: 1, PerMinute: 1}
|
||||
api.mailGate().take(mailGateKey)
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(""))
|
||||
api.noteOTPLock(r, &OTPAccountLockedError{Until: api.now().Add(time.Hour), JustLocked: true}, "u1", otpPurposeLogin)
|
||||
if len(mailer.notices) != 0 {
|
||||
t.Fatalf("notice sent past a spent budget: %q", mailer.notices)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user