fix(api): 登录验证码与 passkey 挑战不再被他人的 start 作废,冷却内重复 start 照常 202,登录挑战按来源限量

This commit is contained in:
Lemon-miaow committed 2026-09-25 16:37:03 +08:00
1 parent 084ba1ed9e
commit 4757353324
22 files changed
+1290 -314

No files matched your search

+3 -2
View File
@@ -962,7 +962,8 @@ func (c *cooldownLimiter) record(name string) {
}
// reserve atomically checks name's cooldown AND, if the window is open, records it
// in the same critical section, returning the reservation time and true. Unlike
// in the same critical section, returning the reservation time and true; inside the
// window it returns the standing reservation's time and false. Unlike
// allowed→record there is no gap between the check and the commit, so a burst of
// truly concurrent callers yields exactly one winner. Use it where the throttle is
// the SOLE defense and each admitted call has a non-idempotent side effect (an OTP
@@ -979,7 +980,7 @@ func (c *cooldownLimiter) reserve(name string, window time.Duration) (time.Time,
t := c.now()
c.noteWindow(window, t)
if last, ok := c.last[name]; ok && t.Sub(last) < window {
return time.Time{}, false
return last, false
}
c.last[name] = t
return t, true
+126 -28
View File
@@ -83,6 +83,9 @@ type fakeRepo struct {
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
// newest live (user, purpose) just as the PG query does.
otps map[string]*fakeEmailOTP
// otpSeq orders codes by insertion (the PG created_at): a frozen test clock mints
// several codes at one instant, so time cannot tell the oldest apart.
otpSeq int
// otpBudget mirrors otp_failure_windows, keyed user|purpose.
otpBudget map[string]*fakeOTPBudget
// op-login requests (spec §B op-login). opLogins mirrors op_login_requests keyed
@@ -140,6 +143,9 @@ type fakePasskeyChallenge struct {
expiresAt time.Time
consumed bool
createdAt time.Time
// challenge and source are set on email-first login rows only (migration 0029).
challenge string
source string
}
// fakeDiscoverableChallenge mirrors a webauthn_discoverable_challenges row (task #40): no user
@@ -149,6 +155,7 @@ type fakeDiscoverableChallenge struct {
sessionData []byte
expiresAt time.Time
consumed bool
source string
}
// fakeDataHold mirrors a player_data_holds row at the granularity the verifiable
@@ -177,10 +184,13 @@ func (f *fakeRepo) OTPLockedUntil(_ context.Context, userID, purpose string, now
return otpLockEnd(b.windowStart, b.failures, now), nil
}
// chargeOTP mirrors chargeOTPMismatch: one wrong guess on the code and the budget.
func (f *fakeRepo) chargeOTP(live *fakeEmailOTP, now time.Time) error {
live.attempts++
key := live.userID + "|" + live.purpose
// chargeOTP mirrors chargeOTPMismatch: one wrong guess on each open code and one on
// the (user, purpose) budget.
func (f *fakeRepo) chargeOTP(open []*fakeEmailOTP, userID, purpose string, now time.Time) error {
for _, o := range open {
o.attempts++
}
key := userID + "|" + purpose
b := f.otpBudget[key]
if b == nil || !b.windowStart.Add(otpFailureWindow).After(now) {
b = &fakeOTPBudget{windowStart: now}
@@ -206,6 +216,7 @@ type fakeEmailOTP struct {
expiresAt time.Time
consumed bool
createdAt time.Time
seq int
}
// fakeSession mirrors a sessions row: its owner, its expiry, and whether it has
@@ -403,12 +414,42 @@ func (f *fakeRepo) CreateEmailOTP(_ context.Context, id, userID, email, codeHash
delete(f.otps, k)
}
}
f.otpSeq++
f.otps[id] = &fakeEmailOTP{
id: id, userID: userID, email: email, codeHash: codeHash, purpose: purpose,
expiresAt: expiresAt, createdAt: expiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation
seq: f.otpSeq,
}
return nil
}
// AddLoginEmailOTP mirrors PGRepo.AddLoginEmailOTP: the live codes of (user, purpose)
// that expired at now go, then all but the newest otpLiveLoginCodes-1, and the new
// code joins the rest.
func (f *fakeRepo) AddLoginEmailOTP(_ context.Context, id, userID, email, codeHash, purpose string, now, expiresAt time.Time) error {
var live []*fakeEmailOTP
for k, o := range f.otps {
if o.userID != userID || o.purpose != purpose || o.consumed {
continue
}
if !o.expiresAt.After(now) {
delete(f.otps, k)
continue
}
live = append(live, o)
}
sort.Slice(live, func(i, j int) bool { return live[i].seq > live[j].seq })
for _, o := range live[min(len(live), otpLiveLoginCodes-1):] {
delete(f.otps, o.id)
}
f.otpSeq++
f.otps[id] = &fakeEmailOTP{
id: id, userID: userID, email: email, codeHash: codeHash, purpose: purpose,
expiresAt: expiresAt, createdAt: now, seq: f.otpSeq,
}
return nil
}
func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash string, now time.Time) (string, error) {
var live *fakeEmailOTP
for _, o := range f.otps { // newest live (user, purpose)
@@ -432,7 +473,7 @@ func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash s
return "", ErrOTPLocked
}
if live.codeHash != codeHash {
return "", f.chargeOTP(live, now) // a typo costs an attempt but does not consume the code
return "", f.chargeOTP([]*fakeEmailOTP{live}, userID, purpose, now) // a typo costs an attempt but does not consume the code
}
// A DIFFERENT verified holder of the same address → ErrEmailTaken, code left
// live — mirrors PGRepo's guard + the users_verified_email_unique index.
@@ -478,6 +519,44 @@ func (f *fakeRepo) CreatePasskeyChallenge(_ context.Context, id, userID, purpose
}
return nil
}
// AddPasskeyLoginChallenge / ConsumePasskeyLoginChallenge mirror PGRepo's email-first
// login pair: begin reaps the account's spent login rows, refuses once source holds
// maxLiveChallengesPerSource live login rows, and stores the challenge beside the
// others; consume redeems the live row whose challenge the browser signed.
func (f *fakeRepo) AddPasskeyLoginChallenge(_ context.Context, id, userID, purpose, source, challenge string, sessionData []byte, now, expiresAt time.Time) error {
fromSource := 0
for k, c := range f.passkeyChallenges {
if c.userID == userID && c.purpose == purpose && (c.consumed || !c.expiresAt.After(now)) {
delete(f.passkeyChallenges, k)
continue
}
if c.source == source && !c.consumed && c.expiresAt.After(now) {
fromSource++
}
}
if fromSource >= maxLiveChallengesPerSource {
return ErrTooManyPasskeyChallenges
}
f.passkeyChallenges[id] = &fakePasskeyChallenge{
id: id, userID: userID, purpose: purpose, sessionData: sessionData,
expiresAt: expiresAt, createdAt: now, challenge: challenge, source: source,
}
return nil
}
func (f *fakeRepo) ConsumePasskeyLoginChallenge(_ context.Context, userID, purpose, challenge string, now time.Time) ([]byte, error) {
for _, c := range f.passkeyChallenges {
if c.userID != userID || c.purpose != purpose || c.challenge != challenge || c.consumed {
continue
}
if !c.expiresAt.After(now) {
return nil, ErrPasskeyChallengeInvalid
}
c.consumed = true
return c.sessionData, nil
}
return nil, ErrPasskeyChallengeInvalid
}
func (f *fakeRepo) ConsumePasskeyChallengeByUser(_ context.Context, userID, purpose string, now time.Time) ([]byte, error) {
var live *fakePasskeyChallenge
for _, c := range f.passkeyChallenges { // newest live (user, purpose)
@@ -496,19 +575,28 @@ func (f *fakeRepo) ConsumePasskeyChallengeByUser(_ context.Context, userID, purp
}
// CreateDiscoverableChallenge / ConsumeDiscoverableChallenge mirror PGRepo's non-user-keyed
// contract (task #40): begin reaps expired/consumed rows then stashes under the opaque handle,
// and consume redeems by handle, single-use, expiry checked. discoverableFull forces the capped
// path so the begin 429 branch is reachable without inserting thousands of rows.
func (f *fakeRepo) CreateDiscoverableChallenge(_ context.Context, id string, sessionData []byte, now, expiresAt time.Time) error {
// contract (task #40): begin reaps expired/consumed rows, refuses once source holds
// maxLiveChallengesPerSource live rows, then stashes under the opaque handle; consume redeems
// by handle, single-use, expiry checked. discoverableFull forces the global cap so the begin
// 429 branch is reachable without inserting thousands of rows.
func (f *fakeRepo) CreateDiscoverableChallenge(_ context.Context, id, source string, sessionData []byte, now, expiresAt time.Time) error {
if f.discoverableFull {
return ErrTooManyDiscoverableChallenges
return ErrTooManyPasskeyChallenges
}
fromSource := 0
for k, c := range f.discoverableChallenges { // reap (DELETE ... expires_at<=now OR consumed_at NOT NULL)
if c.consumed || !c.expiresAt.After(now) {
delete(f.discoverableChallenges, k)
continue
}
if c.source == source {
fromSource++
}
}
f.discoverableChallenges[id] = &fakeDiscoverableChallenge{sessionData: sessionData, expiresAt: expiresAt}
if fromSource >= maxLiveChallengesPerSource {
return ErrTooManyPasskeyChallenges
}
f.discoverableChallenges[id] = &fakeDiscoverableChallenge{sessionData: sessionData, expiresAt: expiresAt, source: source}
return nil
}
func (f *fakeRepo) ConsumeDiscoverableChallenge(_ context.Context, id string, now time.Time) ([]byte, error) {
@@ -624,6 +712,9 @@ type fakePasskeyVerifier struct {
// stashed SessionData round-trips and the existing credentials reach the verifier.
lastUser PasskeyUser
lastSession []byte
// loginSession, when set, is the SessionData BeginLogin hands out in place of the
// per-user marker, so a test can tell two live login ceremonies apart at finish.
loginSession []byte
// discoverableUserHandle is the userHandle the fake feeds to FinishDiscoverableLogin's
// resolver, so a handler test drives the userHandle → UserByID → session-mint wiring for a
// chosen account (or an unknown handle, to exercise the resolve-fails branch).
@@ -657,6 +748,9 @@ func (v *fakePasskeyVerifier) BeginLogin(user PasskeyUser) (json.RawMessage, []b
if opts == nil {
opts = json.RawMessage(`{"publicKey":{"challenge":"YXNzZXJ0"}}`)
}
if v.loginSession != nil {
return opts, v.loginSession, nil
}
return opts, []byte("login-session:" + user.ID), nil
}
@@ -1473,36 +1567,40 @@ func (f *fakeRepo) UserByEmail(_ context.Context, email string) (*StaffUser, err
return nil, ErrNotFound
}
// ConsumeLoginEmailOTP mirrors PGRepo.ConsumeLoginEmailOTP: it redeems the newest
// live code for (user, purpose) WITHOUT the identity side-effect (login already
// resolved the userID via UserByEmail, so the address is settled). It charges an
// attempt on a hash mismatch (exactly like VerifyEmailOTP) but never writes
// users.email or runs the verified-email guard. A missing/expired/consumed code →
// ErrOTPInvalid; a mismatch → ErrOTPInvalid too (and costs an attempt without
// consuming); a locked code → ErrOTPLocked; a match → consumed, nil.
// ConsumeLoginEmailOTP mirrors PGRepo.ConsumeLoginEmailOTP: every live (unconsumed,
// unexpired) code of (user, purpose) is a candidate. Nothing live → ErrOTPInvalid;
// the account lock next; every live code out of attempts → ErrOTPLocked; no match →
// one attempt on each open code plus one budget failure, nothing consumed; a match
// spends every live code. No identity side-effect (login already resolved the
// userID via UserByEmail, so the address is settled).
func (f *fakeRepo) ConsumeLoginEmailOTP(_ context.Context, userID, purpose, codeHash string, now time.Time) error {
var live *fakeEmailOTP
for _, o := range f.otps { // newest live (user, purpose), mirroring VerifyEmailOTP
if o.userID != userID || o.purpose != purpose || o.consumed {
var live, open []*fakeEmailOTP
matched := false
for _, o := range f.otps {
if o.userID != userID || o.purpose != purpose || o.consumed || !o.expiresAt.After(now) {
continue
}
if live == nil || o.createdAt.After(live.createdAt) {
live = o
live = append(live, o)
if o.attempts < otpMaxAttempts {
open = append(open, o)
matched = matched || o.codeHash == codeHash
}
}
if live == nil || !live.expiresAt.After(now) {
if len(live) == 0 {
return ErrOTPInvalid
}
if until, _ := f.OTPLockedUntil(context.Background(), userID, purpose, now); !until.IsZero() {
return &OTPAccountLockedError{Until: until}
}
if live.attempts >= otpMaxAttempts {
if len(open) == 0 {
return ErrOTPLocked
}
if live.codeHash != codeHash {
return f.chargeOTP(live, now) // a typo costs an attempt but does not consume the code
if !matched {
return f.chargeOTP(open, userID, purpose, now) // a typo costs an attempt but consumes nothing
}
for _, o := range live {
o.consumed = true
}
live.consumed = true
return nil
}
+7 -8
View File
@@ -87,14 +87,13 @@ var (
// guard and gets a 409 instead of a raw unique-violation 500. Distinct from
// ErrConflict so the message can name the cause (the email is spoken for).
ErrEmailTaken = errors.New("email already verified on another account")
// ErrTooManyDiscoverableChallenges means the non-user-keyed discoverable ("usernameless")
// login challenge store is at its hard cap of live rows (task #40, migration 0013).
// Unlike the user-keyed enrollment/login challenges — which self-bound via a per-user
// supersede — a from-zero begin has no principal to key a fair per-caller limit on, so the
// table is capped globally and a begin over the cap is refused. Distinct from the other
// sentinels so the handler answers 429 (a transient "too busy, retry" — the cap self-clears
// as challenges expire), never a 400 that invites an immediate retry.
ErrTooManyDiscoverableChallenges = errors.New("too many discoverable login challenges in flight")
// ErrTooManyPasskeyChallenges means a passkey login begin was refused because too many
// login challenges are live: the caller's source already holds its allowance
// (maxLiveChallengesPerSource), or the discoverable store is at its global cap
// (maxLiveDiscoverableChallenges). Distinct from the other sentinels so the handler
// answers 429 (a transient "too busy, retry" — both bounds clear as challenges expire),
// never a 400 that invites an immediate retry.
ErrTooManyPasskeyChallenges = errors.New("too many passkey login challenges in flight")
// ErrNotStopped means a world-volume operation was refused because the server is
// not fully stopped: desiredState is not Stopped, or its pod is still shutting
// down (phase Stopping) and holds the volume while it saves.
+17 -8
View File
@@ -60,6 +60,11 @@ type loginEmailStartRequest struct {
// no code minted: the response never distinguishes the two, and the reservation is
// kept on that path too so repeated probing of one address is throttled identically
// to repeated sends.
//
// A start never cancels the codes already mailed (AddLoginEmailOTP keeps the newest
// otpLiveLoginCodes live), and a start inside the cooldown answers the same 202 without
// minting: the code mailed moments ago is still good. So anyone who knows an address
// can only add codes to its owner's inbox, never keep the owner from signing in.
func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
@@ -98,8 +103,11 @@ func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) {
lim := a.otpLimiter()
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
"a code was sent recently; wait a moment before requesting another"))
// A start for this address went through less than a cooldown ago, and the code
// it mailed (if the address has an account) is still live. Answer as that start
// did, expiry included, and mail nothing: the owner — or whoever typed the
// address — lands on the code screen and the code already in the inbox works.
writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": emailAt.Add(otpTTL).UTC()})
return
}
committed := false
@@ -109,16 +117,17 @@ func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) {
}
}()
// Compute the expiry once so the neutral (no-account) branch and the real-send
// branch return byte-identical bodies.
expiresAt := a.now().Add(otpTTL)
// Compute the expiry once, from the reservation, so the neutral (no-account)
// branch, the real-send branch and a start inside the window all return
// byte-identical bodies.
expiresAt := emailAt.Add(otpTTL)
u, err := a.Repo.UserByEmail(r.Context(), email)
switch {
case errors.Is(err, ErrNotFound):
// No verified account for this address. Return the same 202 as a real send
// (no code minted) and KEEP the reservation, so probing an unknown address is
// throttled exactly like resending to a known one — the throttle reveals
// (no code minted) and KEEP the reservation, so a probe of an unknown address
// holds the window exactly like a send to a known one — the window reveals
// nothing, and the accepted /auth/options oracle is where existence is learnt.
committed = true
writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()})
@@ -158,7 +167,7 @@ func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) {
// record. The login redeem (ConsumeLoginEmailOTP) never reads or writes this
// address, so the stored casing is authoritative and the row's email snapshot is
// purely for the audit trail.
if err := a.Repo.CreateEmailOTP(r.Context(), id, u.ID, u.Email, otpCodeHash(code), otpPurposeLogin, expiresAt); err != nil {
if err := a.Repo.AddLoginEmailOTP(r.Context(), id, u.ID, u.Email, otpCodeHash(code), otpPurposeLogin, a.now(), expiresAt); err != nil {
writeError(w, r, err)
return
}
+121 -17
View File
@@ -152,8 +152,7 @@ func TestLoginEmailVertical(t *testing.T) {
// TestLoginEmailStartNeutralOnUnknownAddress pins the start-side anti-enumeration
// contract: an address with no verified account yields a 202 BYTE-IDENTICAL to a
// real send (frozen clock ⇒ same expires_at), mints and mails nothing, audits
// nothing — and still burns the cooldown window, so probing is throttled exactly
// like sending.
// nothing — and a re-probe inside the cooldown answers the same 202 a resend does.
func TestLoginEmailStartNeutralOnUnknownAddress(t *testing.T) {
// A real send for comparison.
apiK, _, _ := seedLoginEmailAPI(t)
@@ -183,12 +182,14 @@ func TestLoginEmailStartNeutralOnUnknownAddress(t *testing.T) {
t.Errorf("neutral path must mint/mail/audit nothing, got otps=%d mails=%d audits=%d",
len(repoU.otps), mailerU.calls, len(repoU.audits))
}
// The reservation is KEPT on the neutral path: re-probing the same unknown
// address inside the window is throttled identically to a resend.
if w := do(ehU, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("re-probe of unknown address: code = %d body %s, want 429 otp_resend_cooldown",
// Re-probing inside the window answers exactly as the first probe did.
if w := do(ehU, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted || w.Body.String() != wK.Body.String() {
t.Fatalf("re-probe of unknown address: code = %d body %s, want the same 202 as a real send",
w.Code, w.Body.String())
}
if len(repoU.otps) != 0 || mailerU.calls != 0 {
t.Errorf("re-probe must mint/mail nothing, got otps=%d mails=%d", len(repoU.otps), mailerU.calls)
}
// An UNVERIFIED account is indistinguishable from no account: UserByEmail only
// resolves proven addresses, so the door never mails one nobody controls.
@@ -278,13 +279,14 @@ func TestLoginEmailGates(t *testing.T) {
// TestLoginEmailStartRateLimited closes the unauthenticated email-bomb vector on the
// public door: one send per recipient per window, keyed case-insensitively, and
// namespaced apart from the authenticated onboarding throttle so neither door can
// starve the other.
// starve the other. A start inside the window is answered like the one that sent,
// so whoever asks lands on the code screen with the mail already in the inbox.
func TestLoginEmailStartRateLimited(t *testing.T) {
start := func(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/email/start", `{"email":"`+email+`"}`, jsonHeader)
}
t.Run("same recipient is throttled, then recovers after the cooldown", func(t *testing.T) {
t.Run("a start inside the window mails nothing and keeps the first code", func(t *testing.T) {
api, repo, mailer := seedLoginEmailAPI(t)
clock := time.Unix(1_700_000_000, 0)
api.Now = func() time.Time { return clock }
@@ -293,28 +295,41 @@ func TestLoginEmailStartRateLimited(t *testing.T) {
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
t.Fatalf("first send: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
if w := start(eh, "[email protected]"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("immediate resend: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
code := mailer.code
clock = clock.Add(30 * time.Second)
w := start(eh, "[email protected]")
if w.Code != http.StatusAccepted {
t.Fatalf("resend inside the window: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
// The expiry is the first code's, the one the inbox holds.
if b := acctBody(t, w); b["sent"] != true || b["expires_at"] != "2023-11-14T22:23:20Z" {
t.Errorf("resend body = %v, want sent:true expires_at:2023-11-14T22:23:20Z", b)
}
if mailer.calls != 1 || len(repo.otps) != 1 {
t.Errorf("throttled resend must not mint or mail: mails=%d otps=%d, want 1/1",
t.Errorf("resend inside the window must not mint or mail: mails=%d otps=%d, want 1/1",
mailer.calls, len(repo.otps))
}
clock = clock.Add(otpResendCooldown + time.Second)
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
t.Fatalf("post-cooldown send: code = %d, want 202 (%s)", w.Code, w.Body.String())
if w := do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"`+code+`"}`, jsonHeader); w.Code != http.StatusOK {
t.Fatalf("first code after a resend: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
clock = clock.Add(otpResendCooldown)
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted || mailer.calls != 2 {
t.Fatalf("post-cooldown send: code = %d mails = %d, want 202 and a second mail (%s)",
w.Code, mailer.calls, w.Body.String())
}
})
t.Run("throttle key is case-insensitive", func(t *testing.T) {
api, _, _ := seedLoginEmailAPI(t)
api, _, mailer := seedLoginEmailAPI(t)
eh := api.ExternalHandler()
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
t.Fatalf("first send: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
// A recased retype is the same mailbox: it must hit the same window.
if w := start(eh, "[email protected]"); w.Code != http.StatusTooManyRequests {
t.Fatalf("recased resend: code = %d, want 429 (key must be lowercased)", w.Code)
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted || mailer.calls != 1 {
t.Fatalf("recased resend: code = %d mails = %d, want 202 and no second mail (key must be lowercased)",
w.Code, mailer.calls)
}
})
@@ -339,6 +354,95 @@ func TestLoginEmailStartRateLimited(t *testing.T) {
})
}
// TestLoginStartsInsideTheWindowMatchExactly: with a clock that moves on every read,
// a start inside the cooldown still answers with the very expires_at the first start
// returned, on both email doors and for known and unknown addresses alike, so a
// repeat start is indistinguishable from the first down to the nanosecond.
func TestLoginStartsInsideTheWindowMatchExactly(t *testing.T) {
ticking := func(api *API) {
clock := time.Unix(1_700_000_000, 0)
api.Now = func() time.Time {
clock = clock.Add(time.Millisecond)
return clock
}
}
expiry := func(t *testing.T, w *httptest.ResponseRecorder) string {
t.Helper()
if w.Code != http.StatusAccepted {
t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
e, _ := acctBody(t, w)["expires_at"].(string)
return e
}
for _, email := range []string{"[email protected]", "[email protected]"} {
api, _, _ := seedLoginEmailAPI(t)
ticking(api)
eh := api.ExternalHandler()
start := func() *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/email/start", `{"email":"`+email+`"}`, jsonHeader)
}
first, again := expiry(t, start()), expiry(t, start())
if first != "2023-11-14T22:23:20.001Z" || again != first {
t.Errorf("email door %s: expires_at %q then %q, want 2023-11-14T22:23:20.001Z twice", email, first, again)
}
}
for _, email := range []string{"[email protected]", "[email protected]"} {
api, _, _ := seedOpLoginAPI(t)
ticking(api)
eh := api.ExternalHandler()
first, again := expiry(t, startOp(eh, email)), expiry(t, startOp(eh, email))
if first != "2023-11-14T22:23:20.001Z" || again != first {
t.Errorf("op door %s: expires_at %q then %q, want 2023-11-14T22:23:20.001Z twice", email, first, again)
}
}
}
// TestLoginEmailStartKeepsEarlierCodes is the stranger-keeps-starting case: someone
// who knows the address starts a login every cooldown. Each start adds a code to the
// owner's inbox and never cancels one, so the owner's own code keeps working until
// otpLiveLoginCodes newer ones exist; signing in spends every code still out.
func TestLoginEmailStartKeepsEarlierCodes(t *testing.T) {
api, repo, mailer := seedLoginEmailAPI(t)
clock := time.Unix(1_700_000_000, 0)
api.Now = func() time.Time { return clock }
eh := api.ExternalHandler()
start := func() string {
t.Helper()
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
code := mailer.code
clock = clock.Add(otpResendCooldown)
return code
}
verify := func(code string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"`+code+`"}`, jsonHeader)
}
owner := start()
second := start()
third := start()
if mailer.calls != 3 || len(repo.otps) != 3 {
t.Fatalf("mails=%d otps=%d, want 3/3 (a start must not cancel earlier codes)", mailer.calls, len(repo.otps))
}
fourth := start()
if len(repo.otps) != 3 {
t.Fatalf("otps = %d after a fourth start, want 3 (the oldest goes)", len(repo.otps))
}
if w := verify(owner); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("code with three newer ones: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
}
if w := verify(second); w.Code != http.StatusOK {
t.Fatalf("second code while two newer are live: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
for name, code := range map[string]string{"third": third, "fourth": fourth} {
if w := verify(code); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Errorf("%s code after the sign-in: code = %d body %s, want 400 invalid_code", name, w.Code, w.Body.String())
}
}
}
// TestLoginEmailVerifyRejections is the redeem-side failure matrix. The anchor case
// is uniformity: an unknown address and a wrong code for a known address answer with
// the same (code, message) envelope, so the verify half never doubles as an
+9
View File
@@ -57,6 +57,15 @@ const (
// use a passkey.
otpFailureBudget = 10
otpFailureWindow = 24 * time.Hour
// otpLiveLoginCodes is how many codes a pre-session login door (email login,
// op-login) keeps redeemable per (account, purpose). Anyone who knows an address
// can start a login for it, so a start never cancels the codes already mailed:
// with one mail per otpResendCooldown, every code stays good for at least
// otpLiveLoginCodes cooldowns (or its TTL), and a stranger's starts only add
// codes to the owner's inbox. A wrong guess is compared with every live code, so
// the daily blind-hit chance rises to otpFailureBudget*otpLiveLoginCodes/1e6
// (3e-5). Enforced in the Repo so the fake and PG agree.
otpLiveLoginCodes = 3
)
// OTPMailer delivers a one-time code to an email address. It is a seam, not a
+34 -20
View File
@@ -65,6 +65,12 @@ type opLoginStartRequest struct {
// unknown address yields the SAME 202 with a random, non-persisted handle and no mail,
// so this never doubles as a staff-enumeration oracle (op.console's own Zero-Trust is
// the edge gate; this app-layer neutrality covers the hostname-agnostic route).
//
// Anyone who knows a staff address can start a login for it, so a start never cancels
// the codes already mailed (AddLoginEmailOTP), and a start inside the per-recipient
// cooldown still gets a request of its own but mails nothing: the code mailed moments
// ago is live and finishes it. A stranger's starts therefore only add codes to the
// staff inbox and never keep its owner from signing in.
func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
@@ -94,33 +100,32 @@ func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) {
}
// 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
// key is namespaced apart from the console door's "login:email:" so the two
// door: one mail per window, and the neutral (non-staff) branch keeps the
// reservation too so a probe holds the window exactly like a real send. The key is
// namespaced apart from the console door's "login:email:" so the two
// unauthenticated doors never perturb each other's throttle.
emailKey := "oplogin:email:" + strings.ToLower(email)
lim := a.otpLimiter()
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
"a code was sent recently; wait a moment before requesting another"))
return
}
emailAt, fresh := lim.reserve(emailKey, otpResendCooldown)
committed := false
defer func() {
if !committed {
lim.release(emailKey, emailAt)
}
}()
if fresh {
defer func() {
if !committed {
lim.release(emailKey, emailAt)
}
}()
}
// Compute expiry once so the neutral and real branches return identical-shaped
// bodies and (real branch) the request row and its OTP are coterminous.
expiresAt := a.now().Add(otpTTL)
// Compute expiry once, from the reservation, so the neutral and real branches
// return identical bodies and the request row and its OTP are coterminous. Inside
// the cooldown the live code is the one the standing reservation mailed, so the
// request ends with it.
expiresAt := emailAt.Add(otpTTL)
// neutral returns the indistinguishable no-op success: a plausible but non-persisted
// handle that status(id) reads approved:false forever (no row, never approvable). It
// mints nothing and mails nothing, and KEEPS the reservation so probing is throttled
// exactly like a real send.
// mints nothing and mails nothing, and KEEPS the reservation so a probe holds the
// window exactly like a real send.
neutral := func() {
fakeID, err := newOTPID()
if err != nil {
@@ -172,6 +177,15 @@ func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
if !fresh {
// Inside the cooldown: the code mailed with the standing reservation finishes
// this request too, and the mailbox still sees one code per window.
a.auditAccount(r, u, "auth.op_login.requested", "")
writeJSON(w, http.StatusAccepted, map[string]any{
"request_id": id, "expires_at": expiresAt.UTC(),
})
return
}
code, err := newEmailOTP()
if err != nil {
writeError(w, r, err)
@@ -184,7 +198,7 @@ func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) {
}
// Mint+mail against the STORED staff address (UserByEmail matched case-insensitively);
// the request row snapshots the same address for its audit trail.
if err := a.Repo.CreateEmailOTP(r.Context(), otpID, u.ID, u.Email, otpCodeHash(code), otpPurposeOpLogin, expiresAt); err != nil {
if err := a.Repo.AddLoginEmailOTP(r.Context(), otpID, u.ID, u.Email, otpCodeHash(code), otpPurposeOpLogin, a.now(), expiresAt); err != nil {
writeError(w, r, err)
return
}
+68 -5
View File
@@ -212,8 +212,8 @@ func TestOpLoginOwnerAdmitted(t *testing.T) {
// TestOpLoginStartNeutral pins the start-side anti-enumeration contract: op.console is
// the STAFF door, so a non-admin account AND an unknown address both get a 202 carrying
// a request_id + expires_at, mint/mail nothing, and still burn the per-recipient
// cooldown — so neither the response nor the throttle tells a caller who is staff.
// a request_id + expires_at and mint/mail nothing, and a re-probe inside the cooldown
// does the same — so neither the response nor the throttle tells a caller who is staff.
func TestOpLoginStartNeutral(t *testing.T) {
check := func(t *testing.T, seed func(*fakeRepo), email, wantReason, wantUser string) {
t.Helper()
@@ -248,9 +248,14 @@ func TestOpLoginStartNeutral(t *testing.T) {
repo.audits[0].ActorUserID != wantUser {
t.Errorf("neutral start audits = %+v, want one auth.op_login.failed %s by %q", repo.audits, wantReason, wantUser)
}
// The reservation is KEPT: re-probing the same address is throttled like a resend.
if w := startOp(eh, email); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("re-probe: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
// Re-probing inside the window: the same 202 shape, still nothing behind it.
w = startOp(eh, email)
if b := acctBody(t, w); w.Code != http.StatusAccepted || b["request_id"] == "" || b["expires_at"] != "2023-11-14T22:23:20Z" {
t.Fatalf("re-probe: code = %d body %s, want 202 with a request_id and the first expiry", w.Code, w.Body.String())
}
if len(repo.opLogins) != 0 || len(repo.otps) != 0 || mailer.calls != 0 {
t.Errorf("re-probe must mint/mail nothing: reqs=%d otps=%d mails=%d",
len(repo.opLogins), len(repo.otps), mailer.calls)
}
}
@@ -264,6 +269,64 @@ func TestOpLoginStartNeutral(t *testing.T) {
})
}
// TestOpLoginStartByAStranger is the case of someone who knows a staff address and
// keeps starting logins for it. Their start mails the code to the staff inbox; the
// staff member's own start inside the cooldown still gets a request of its own
// (nothing new mailed, same expiry as the live code), and that inbox code finishes
// it. A start after the cooldown mails a second code without cancelling the first.
func TestOpLoginStartByAStranger(t *testing.T) {
api, repo, mailer := seedOpLoginAPI(t)
clock := time.Unix(1_700_000_000, 0)
api.Now = func() time.Time { return clock }
eh := api.ExternalHandler()
ih := api.InternalHandler()
requestID := func(w *httptest.ResponseRecorder) string {
t.Helper()
if w.Code != http.StatusAccepted {
t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
id, _ := acctBody(t, w)["request_id"].(string)
return id
}
strangers := requestID(startOp(eh, "[email protected]"))
inboxCode := mailer.code
clock = clock.Add(30 * time.Second)
w := startOp(eh, "[email protected]")
own := requestID(w)
if own == strangers || repo.opLogins[own] == nil {
t.Fatalf("own request %q must be a new, stored request beside the stranger's %q", own, strangers)
}
if b := acctBody(t, w); b["expires_at"] != "2023-11-14T22:23:20Z" {
t.Errorf("own start expires_at = %v, want 2023-11-14T22:23:20Z (the live code's)", b["expires_at"])
}
if mailer.calls != 1 || len(repo.otps) != 1 {
t.Fatalf("start inside the cooldown: mails=%d otps=%d, want 1/1", mailer.calls, len(repo.otps))
}
if w := approveOp(ih, own, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: code = %d (%s)", w.Code, w.Body.String())
}
if w := finishOp(eh, own, inboxCode); w.Code != http.StatusOK {
t.Fatalf("finish own request with the inbox code: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
// After the cooldown a start mails a second code; the first one still works.
clock = clock.Add(otpResendCooldown)
first := requestID(startOp(eh, "[email protected]"))
firstCode := mailer.code
clock = clock.Add(otpResendCooldown)
requestID(startOp(eh, "[email protected]"))
if mailer.calls != 3 {
t.Fatalf("mails = %d, want 3", mailer.calls)
}
if w := approveOp(ih, first, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: code = %d (%s)", w.Code, w.Body.String())
}
if w := finishOp(eh, first, firstCode); w.Code != http.StatusOK {
t.Fatalf("finish with the earlier code after a newer start: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
}
// TestOpLoginStatusNeutral proves status is never an enumeration oracle: it returns
// approved:true ONLY for a genuinely approved, live, unconsumed request, and
// approved:false (never 404) for an unknown, expired, denied, or consumed handle — all
+89 -32
View File
@@ -4,9 +4,11 @@ import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
@@ -241,6 +243,61 @@ func unwrapPublicKey(options json.RawMessage) json.RawMessage {
return env.PublicKey
}
// optionsChallenge returns the challenge in go-webauthn's request options
// ({"publicKey": {"challenge": ...}}) in canonical form: the value the browser will
// sign into the assertion's clientDataJSON.
func optionsChallenge(options json.RawMessage) (string, error) {
var env struct {
PublicKey struct {
Challenge string `json:"challenge"`
} `json:"publicKey"`
}
if err := json.Unmarshal(options, &env); err != nil {
return "", err
}
return canonicalChallenge(env.PublicKey.Challenge)
}
// assertionChallenge returns, in canonical form, the challenge a
// navigator.credentials.get() result signed: response.clientDataJSON is base64url
// JSON whose challenge member is that value. It only picks the ceremony to verify
// against; the verifier checks the signature over the same bytes.
func assertionChallenge(assertion json.RawMessage) (string, error) {
var body struct {
Response struct {
ClientDataJSON string `json:"clientDataJSON"`
} `json:"response"`
}
if err := json.Unmarshal(assertion, &body); err != nil {
return "", err
}
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(body.Response.ClientDataJSON, "="))
if err != nil {
return "", err
}
var clientData struct {
Challenge string `json:"challenge"`
}
if err := json.Unmarshal(raw, &clientData); err != nil {
return "", err
}
return canonicalChallenge(clientData.Challenge)
}
// canonicalChallenge decodes a base64url challenge, padded or not (go-webauthn
// accepts both), and re-encodes it unpadded, so the stored and the signed forms
// compare equal. An empty or undecodable challenge is an error.
func canonicalChallenge(s string) (string, error) {
b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "="))
if err != nil {
return "", err
}
if len(b) == 0 {
return "", errors.New("empty challenge")
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// handlePasskeyRegisterBegin mints a credential-creation challenge for the caller
// (spec §14, external app face). It loads the passkeys the caller has already bound so
// the ceremony excludes them (one authenticator binds once), asks the verifier for the
@@ -461,12 +518,16 @@ type passkeyLoginBeginRequest struct {
// (Public, pre-session). It resolves the typed email to an account, loads the
// passkeys that account has bound, and asks the verifier for the assertion options
// + opaque SessionData the browser needs for navigator.credentials.get(). The
// SessionData is stashed under a short TTL, keyed to the user so the finish step
// can consume it. Requires local sessions to be enabled (like the other pre-session
// doors). A user with no bound passkey, an unknown email, and a real account with
// passkeys are distinguished by status code (400 vs 200) — this is an accepted
// enumeration trade-off (the /auth/options oracle is the sanctioned place to learn
// existence), but the per-recipient cooldown below makes probing impractical.
// SessionData is stashed under a short TTL beside the account's other live login
// ceremonies, tagged with the challenge the browser will sign, so finish consumes
// exactly the ceremony it answers: anyone who knows the address can begin a login for
// it, and a begin never cancels the owner's. The store holds each network to
// maxLiveChallengesPerSource live login challenges (429 too_many_challenges past it).
// Requires local sessions to be enabled (like the other pre-session doors). A user
// with no bound passkey, an unknown email, and a real account with passkeys are
// distinguished by status code (400 vs 200) — this is an accepted enumeration
// trade-off (the /auth/options oracle is the sanctioned place to learn existence);
// volume per caller is bounded by the auth-door bucket.
func (a *API) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
@@ -492,29 +553,9 @@ func (a *API) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
return
}
// Per-recipient cooldown reserved BEFORE any work, identical to the email-OTP and
// op-login doors: one winner per window, so a burst of probes is throttled. The
// key is namespaced apart from the other pre-session doors so they never perturb
// each other's throttle.
emailKey := "passkey:login:" + strings.ToLower(email)
lim := a.otpLimiter()
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
"a passkey login was started recently; wait a moment before requesting another"))
return
}
committed := false
defer func() {
if !committed {
lim.release(emailKey, emailAt)
}
}()
u, err := a.Repo.UserByEmail(r.Context(), email)
if err != nil {
if errors.Is(err, ErrNotFound) {
committed = true // keep the reservation so probing is throttled
writeError(w, r, newError(http.StatusBadRequest, "no_passkey",
"no passkey enrolled for this account; use email or operator login"))
return
@@ -529,7 +570,6 @@ func (a *API) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
return
}
if len(creds) == 0 {
committed = true
writeError(w, r, newError(http.StatusBadRequest, "no_passkey",
"no passkey enrolled for this account; use email or operator login"))
return
@@ -547,17 +587,26 @@ func (a *API) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
"could not start passkey login"))
return
}
challenge, err := optionsChallenge(options)
if err != nil {
writeError(w, r, fmt.Errorf("passkey login options carry no challenge: %w", err))
return
}
id, err := newPasskeyID()
if err != nil {
writeError(w, r, err)
return
}
expiresAt := a.now().Add(passkeyChallengeTTL)
if err := a.Repo.CreatePasskeyChallenge(r.Context(), id, u.ID, passkeyPurposeLogin, sessionData, expiresAt); err != nil {
now := a.now()
if err := a.Repo.AddPasskeyLoginChallenge(r.Context(), id, u.ID, passkeyPurposeLogin,
challengeSource(a.clientIP(r)), challenge, sessionData, now, now.Add(passkeyChallengeTTL)); err != nil {
if errors.Is(err, ErrTooManyPasskeyChallenges) {
writeError(w, r, errTooManyChallenges)
return
}
writeError(w, r, err)
return
}
committed = true
// go-webauthn wraps the assertion options as {"publicKey": {...}}; the panel's
// username-login flow reads them flat (options.challenge, options.allowCredentials),
// so strip the envelope. (Discoverable login keeps the envelope — see its handler.)
@@ -575,7 +624,8 @@ type passkeyLoginFinishRequest struct {
// handlePasskeyLoginFinish verifies a passkey assertion and mints a session (Public,
// pre-session). It resolves the email to the account, atomically consumes the
// stashed login challenge (a missing or expired one → 400), verifies the assertion
// stashed login challenge the assertion signed (clientDataJSON names it; a missing,
// expired, or unnamed one → 400), verifies the assertion
// against the SessionData, and mints a felis_session. Both players and staff may
// log in this way — the passkey is a two-factor authenticator (possession +
// biometric/PIN), strong enough to stand alone without the in-game approval the
@@ -623,7 +673,14 @@ func (a *API) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
return
}
sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), u.ID, passkeyPurposeLogin, a.now())
challenge, err := assertionChallenge(req.Assertion)
if err != nil {
a.authFailure(r, "passkey", "bad_assertion", u)
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
}
sessionData, err := a.Repo.ConsumePasskeyLoginChallenge(r.Context(), u.ID, passkeyPurposeLogin, challenge, a.now())
if err != nil {
if errors.Is(err, ErrPasskeyChallengeInvalid) {
a.authFailure(r, "passkey", "challenge_invalid", u)
+14 -10
View File
@@ -19,12 +19,17 @@ import (
// and username-first passkey remain the fallbacks, so an authenticator that stored no resident
// key is never locked out — only its from-zero convenience is unavailable.
//
// 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 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).
// Anti-abuse: a usernameless begin has no recipient OR principal to key a limit on, so one client
// is bounded by the per-address token bucket every public auth door sits behind
// (throttleAuthDoor), each network (IPv4 host or IPv6 /48, challengeSource) by
// maxLiveChallengesPerSource live challenges, and the table by a global cap on live challenges;
// CreateDiscoverableChallenge enforces both bounds atomically (ErrTooManyPasskeyChallenges →
// 429). A flood from one network fills its own allowance and leaves every other network its
// sign-ins.
// errTooManyChallenges answers a passkey login begin over a challenge bound.
var errTooManyChallenges = newError(http.StatusTooManyRequests, "too_many_challenges",
"too many passkey logins in progress from this network; try again in a few minutes")
// handlePasskeyLoginDiscoverableBegin starts a usernameless assertion ceremony (Public,
// pre-session). It has no request body — the whole point is that the caller supplies no
@@ -59,10 +64,9 @@ func (a *API) handlePasskeyLoginDiscoverableBegin(w http.ResponseWriter, r *http
return
}
now := a.now()
if err := a.Repo.CreateDiscoverableChallenge(r.Context(), id, sessionData, now, now.Add(passkeyChallengeTTL)); err != nil {
if errors.Is(err, ErrTooManyDiscoverableChallenges) {
writeError(w, r, newError(http.StatusTooManyRequests, "too_many_challenges",
"too many passkey logins in progress; try again shortly"))
if err := a.Repo.CreateDiscoverableChallenge(r.Context(), id, challengeSource(a.clientIP(r)), sessionData, now, now.Add(passkeyChallengeTTL)); err != nil {
if errors.Is(err, ErrTooManyPasskeyChallenges) {
writeError(w, r, errTooManyChallenges)
return
}
writeError(w, r, err)
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -136,11 +137,10 @@ func TestPasskeyDiscoverableLoginVertical(t *testing.T) {
}
}
// TestPasskeyDiscoverableLoginBeginCapped pins the server-side volumetric bound: with the store
// at its hard cap, begin answers 429 too_many_challenges and stashes nothing. This is the only
// per-server brake on the usernameless begin (there is no recipient/principal to key a per-caller
// cooldown on, so volumetric per-source limiting is delegated to the edge) — a reap alone cannot
// bound a burst, since freshly-inserted rows are not yet expired.
// TestPasskeyDiscoverableLoginBeginCapped pins the store-wide bound: with the store at its hard
// cap, begin answers 429 too_many_challenges and stashes nothing. A reap alone cannot bound a
// burst, since freshly-inserted rows are not yet expired; the per-source bound below keeps one
// network from reaching this cap.
func TestPasskeyDiscoverableLoginBeginCapped(t *testing.T) {
api, repo, _ := seedDiscoverableLoginAPI(t)
repo.discoverableFull = true
@@ -155,6 +155,35 @@ func TestPasskeyDiscoverableLoginBeginCapped(t *testing.T) {
}
}
// TestPasskeyDiscoverableLoginBeginPerSourceCap: the usernameless begin has no account to key
// on, so the store holds each network (IPv4 address, IPv6 /48) to 32 live challenges. The
// begins come from 33 different /64s inside one /48, so the per-/64 auth-door bucket never
// trips and only the /48 bound refuses the last; another network still begins.
func TestPasskeyDiscoverableLoginBeginPerSourceCap(t *testing.T) {
api, repo, _ := seedDiscoverableLoginAPI(t)
api.ClientIPHeader = "CF-Connecting-IP"
eh := api.ExternalHandler()
from := func(ip string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/passkey/login/discoverable/begin", `{}`,
map[string]string{"Content-Type": "application/json", "CF-Connecting-IP": ip})
}
for i := 1; i <= 32; i++ {
if w := from(fmt.Sprintf("2001:db8:7:%x::1", i)); w.Code != http.StatusOK {
t.Fatalf("begin %d: code = %d, want 200 (%s)", i, w.Code, w.Body.String())
}
}
w := from("2001:db8:7:ff::1")
if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "too_many_challenges" {
t.Fatalf("33rd begin from the /48: code = %d body %s, want 429 too_many_challenges", w.Code, w.Body.String())
}
if len(repo.discoverableChallenges) != 32 {
t.Errorf("stashed = %d, want 32", len(repo.discoverableChallenges))
}
if w := from("2001:db8:8::1"); w.Code != http.StatusOK {
t.Fatalf("begin from another /48: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
}
// TestPasskeyDiscoverableLoginBeginVerifierError pins the verifier-fault path: a
// BeginDiscoverableLogin failure is a server-side fault, answered passkey_login_failed, and
// stashes no challenge (there is nothing to stash — the ceremony never started).
+147 -74
View File
@@ -2,8 +2,10 @@ package api
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -23,8 +25,9 @@ import (
// - Anti-enumeration on finish: unknown email, no live challenge, expired challenge and
// a bad assertion all collapse to ONE passkey_login_invalid envelope, so the finish
// half is never an existence/state oracle.
// - Cooldown seals the accepted begin-side trade-off: has-passkey (200) vs no_passkey
// (400) is a status oracle, but one begin per recipient per window throttles probing.
// - Ceremonies coexist: finish picks the live challenge the browser signed, so a
// begin by someone else who knows the address never cancels the owner's, and each
// network holds a bounded number of live login challenges.
// - Staff admitted: unlike the email door's staff_account refusal, a passkey stands
// alone (possession + user-verification), so role=admin mints a session here.
@@ -57,14 +60,30 @@ func seedLoginPasskeyAPI(t *testing.T) (*API, *fakeRepo, *fakePasskeyVerifier) {
// plantLoginChallenge seeds a stashed LOGIN-purpose challenge for u1 directly, so the
// finish-side branches (expired, verification failure) are reachable under the frozen
// clock without running begin first. Mirrors plantPasskeyChallenge, but scoped to
// passkeyPurposeLogin so it is only ever consumed by the login door.
// passkeyPurposeLogin so it is only ever consumed by the login door. Its challenge is
// the one the fake verifier hands out by default, so loginAssertion("cred-1",
// "YXNzZXJ0") answers it.
func plantLoginChallenge(repo *fakeRepo, id string, expiresAt time.Time) {
repo.passkeyChallenges[id] = &fakePasskeyChallenge{
id: id, userID: "u1", purpose: passkeyPurposeLogin,
id: id, userID: "u1", purpose: passkeyPurposeLogin, challenge: "YXNzZXJ0", source: "192.0.2.1",
sessionData: []byte("login-session:u1"), expiresAt: expiresAt, createdAt: expiresAt,
}
}
// loginAssertion is the JSON a browser posts back from navigator.credentials.get(): the
// credential id plus response.clientDataJSON, the base64url JSON that carries the signed
// challenge.
func loginAssertion(credID, challenge string) string {
clientData := base64.RawURLEncoding.EncodeToString(
[]byte(`{"type":"webauthn.get","challenge":"` + challenge + `","origin":"https://console.example.net"}`))
return `{"id":"` + credID + `","type":"public-key","response":{"clientDataJSON":"` + clientData + `"}}`
}
// finishBody is a username-first finish body answering challenge with cred-1.
func finishBody(email, challenge string) string {
return `{"email":"` + email + `","assertion":` + loginAssertion("cred-1", challenge) + `}`
}
// TestPasskeyLoginVertical walks the whole returning-player slice across the external
// face: begin resolves the mixed-case account from a lowercase-typed email, hands its
// bound credential to the verifier, returns the assertion options verbatim and stashes
@@ -105,8 +124,7 @@ func TestPasskeyLoginVertical(t *testing.T) {
// 2) finish — typed in yet another casing, proving the finish-side resolver is
// case-insensitive too — verifies the assertion and mints the session. The body
// carries NO challenge, only email+assertion.
w = do(eh, "POST", "/api/v1/auth/passkey/login/finish",
`{"email":"[email protected]","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
w = do(eh, "POST", "/api/v1/auth/passkey/login/finish", finishBody("[email protected]", "YXNzZXJ0"), jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
@@ -144,8 +162,7 @@ func TestPasskeyLoginVertical(t *testing.T) {
}
// 3) single-use: the consumed challenge buys nothing a second time.
if w := do(eh, "POST", "/api/v1/auth/passkey/login/finish",
`{"email":"[email protected]","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
if w := do(eh, "POST", "/api/v1/auth/passkey/login/finish", finishBody("[email protected]", "YXNzZXJ0"), jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
t.Fatalf("replay of consumed challenge: code = %d body %s, want 400 passkey_login_invalid", w.Code, w.Body.String())
}
}
@@ -153,8 +170,8 @@ func TestPasskeyLoginVertical(t *testing.T) {
// TestPasskeyLoginBeginNoPasskey pins the begin-side anti-enumeration floor: an unknown
// email and a KNOWN verified account that has enrolled no passkey answer the SAME
// no_passkey envelope, so the two are indistinguishable. (The remaining has-passkey-vs-not
// status split is the documented, accepted trade-off; the cooldown below makes probing
// it impractical.) Neither path stashes a challenge, and both KEEP the reservation.
// status split is the documented, accepted trade-off; the auth-door bucket bounds how
// fast one address can probe it.) Neither path stashes a challenge.
func TestPasskeyLoginBeginNoPasskey(t *testing.T) {
begin := func(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"`+email+`"}`, jsonHeader)
@@ -184,26 +201,11 @@ func TestPasskeyLoginBeginNoPasskey(t *testing.T) {
len(repoU.passkeyChallenges), len(repoN.passkeyChallenges))
}
})
t.Run("the no_passkey path KEEPS the reservation so probing is throttled", func(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
eh := api.ExternalHandler()
if w := begin(eh, "[email protected]"); w.Code != http.StatusBadRequest || decodeErr(t, w) != "no_passkey" {
t.Fatalf("first probe: code = %d body %s, want 400 no_passkey", w.Code, w.Body.String())
}
// Re-probing the same unknown address inside the window is throttled identically to
// a real begin — the response is not the only channel; the throttle is sealed too.
if w := begin(eh, "[email protected]"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("re-probe: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
}
})
}
// TestPasskeyLoginBeginVerifierError pins the reserve→rollback path: a BeginLogin failure
// is a server-side fault, not a probe signal, so it answers passkey_login_failed AND
// RELEASES the reservation — the immediate retry is admitted, not 429'd. Distinguishing
// 400-not-429 on the retry is what proves the release: a kept reservation would 429 before
// ever reaching BeginLogin.
// TestPasskeyLoginBeginVerifierError pins the verifier-fault path: a BeginLogin failure is
// a server-side fault, answered passkey_login_failed, stashing nothing, and the immediate
// retry reaches the verifier again.
func TestPasskeyLoginBeginVerifierError(t *testing.T) {
api, repo, v := seedLoginPasskeyAPI(t)
v.beginLoginErr = errors.New("no assertable credential")
@@ -216,7 +218,7 @@ func TestPasskeyLoginBeginVerifierError(t *testing.T) {
t.Errorf("a failed begin must stash no challenge, got %d", len(repo.passkeyChallenges))
}
if w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_failed" {
t.Fatalf("retry after verifier error: code = %d body %s, want 400 passkey_login_failed (reservation must be released, not 429)", w.Code, w.Body.String())
t.Fatalf("retry after verifier error: code = %d body %s, want 400 passkey_login_failed", w.Code, w.Body.String())
}
}
@@ -317,32 +319,41 @@ func TestPasskeyLoginGates(t *testing.T) {
// directly: the frozen clock makes that the only deterministic route to that branch.
func TestPasskeyLoginFinishRejections(t *testing.T) {
const finishPath = "/api/v1/auth/passkey/login/finish"
finish := func(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", finishPath,
`{"email":"`+email+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
finish := func(eh http.Handler, email, assertion string) *httptest.ResponseRecorder {
if assertion == "" {
assertion = loginAssertion("cred-1", "YXNzZXJ0")
}
return do(eh, "POST", finishPath, `{"email":"`+email+`","assertion":`+assertion+`}`, jsonHeader)
}
cases := []struct {
name string
email string
setup func(repo *fakeRepo, v *fakePasskeyVerifier)
name string
email string
assertion string // empty: cred-1 over the planted challenge
setup func(repo *fakeRepo, v *fakePasskeyVerifier)
}{
{"unknown email", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
{"known account, no live challenge", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
{"expired challenge", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {
{"unknown email", "[email protected]", "", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
{"known account, no live challenge", "[email protected]", "", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
{"expired challenge", "[email protected]", "", func(repo *fakeRepo, v *fakePasskeyVerifier) {
plantLoginChallenge(repo, "ex", frozenNow.Add(-time.Second))
}},
{"assertion fails verification", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {
{"assertion fails verification", "[email protected]", "", func(repo *fakeRepo, v *fakePasskeyVerifier) {
plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL))
v.failErr = errors.New("bad assertion")
}},
{"assertion signs a challenge nobody issued", "[email protected]", loginAssertion("cred-1", "bm9ib2R5"), func(repo *fakeRepo, v *fakePasskeyVerifier) {
plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL))
}},
{"assertion without clientDataJSON", "[email protected]", `{"id":"cred-1","type":"public-key"}`, func(repo *fakeRepo, v *fakePasskeyVerifier) {
plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL))
}},
}
var envelopes [][2]string
for _, c := range cases {
api, repo, v := seedLoginPasskeyAPI(t)
c.setup(repo, v)
w := finish(api.ExternalHandler(), c.email)
w := finish(api.ExternalHandler(), c.email, c.assertion)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s: code = %d, want 400 (%s)", c.name, w.Code, w.Body.String())
}
@@ -368,43 +379,106 @@ func TestPasskeyLoginFinishRejections(t *testing.T) {
}
}
// TestPasskeyLoginBeginRateLimited closes the unauthenticated probing/DoS vector on the
// public door: one begin per recipient per window, keyed case-insensitively (a recased
// retype is the same mailbox), recovering after the window elapses. This is what makes the
// accepted has-passkey-vs-not status oracle impractical to farm.
func TestPasskeyLoginBeginRateLimited(t *testing.T) {
begin := func(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"`+email+`"}`, jsonHeader)
// TestPasskeyLoginCeremoniesCoexist is the case of someone who knows the address and
// begins logins for it while its owner signs in. Every begin stashes a ceremony of its
// own; finish verifies against the one whose challenge the browser signed, so the
// owner's earlier ceremony survives any number of later begins, and a stranger's
// assertion over a challenge nobody issued consumes nothing.
func TestPasskeyLoginCeremoniesCoexist(t *testing.T) {
api, repo, v := seedLoginPasskeyAPI(t)
eh := api.ExternalHandler()
begin := func(challenge, session string) {
t.Helper()
v.options = json.RawMessage(`{"publicKey":{"challenge":"` + challenge + `"}}`)
v.loginSession = []byte(session)
if w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusOK {
t.Fatalf("begin %s: code = %d, want 200 (%s)", challenge, w.Code, w.Body.String())
}
}
finish := func(challenge string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/passkey/login/finish", finishBody("[email protected]", challenge), jsonHeader)
}
t.Run("same recipient is throttled, then recovers after the cooldown", func(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
clock := frozenNow
api.Now = func() time.Time { return clock }
eh := api.ExternalHandler()
begin("b3duZXI", "owner-session") // the owner's ceremony
begin("c3RyYW5nZXI", "later-begin") // someone else's begin right after
if len(repo.passkeyChallenges) != 2 {
t.Fatalf("live login challenges = %d, want 2 (a begin must not cancel another)", len(repo.passkeyChallenges))
}
if w := begin(eh, "[email protected]"); w.Code != http.StatusOK {
t.Fatalf("first begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := begin(eh, "[email protected]"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("immediate re-begin: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
}
clock = clock.Add(otpResendCooldown + time.Second)
if w := begin(eh, "[email protected]"); w.Code != http.StatusOK {
t.Fatalf("post-cooldown begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
})
if w := finish("bm9ib2R5"); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
t.Fatalf("finish over an unissued challenge: code = %d body %s, want 400 passkey_login_invalid", w.Code, w.Body.String())
}
if w := finish("b3duZXI"); w.Code != http.StatusOK {
t.Fatalf("owner's finish after a later begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if string(v.lastSession) != "owner-session" {
t.Errorf("owner's finish verified against %q, want owner-session", v.lastSession)
}
if w := finish("c3RyYW5nZXI"); w.Code != http.StatusOK || string(v.lastSession) != "later-begin" {
t.Fatalf("later ceremony: code = %d session %q, want 200 later-begin", w.Code, v.lastSession)
}
if w := finish("b3duZXI"); w.Code != http.StatusBadRequest {
t.Fatalf("replay of the owner's challenge: code = %d, want 400", w.Code)
}
}
t.Run("throttle key is case-insensitive", func(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
eh := api.ExternalHandler()
if w := begin(eh, "[email protected]"); w.Code != http.StatusOK {
t.Fatalf("first begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
// TestPasskeyLoginBeginPerSourceCap bounds the challenge store by network: 32 live login
// challenges begun from one IPv6 /48 fill that network's allowance (429
// too_many_challenges, nothing stashed), while a begin from another network still gets
// its ceremony. The begins come from 33 different /64s inside the /48, so the per-/64
// auth-door bucket never trips and only the /48 bound can refuse the last one.
func TestPasskeyLoginBeginPerSourceCap(t *testing.T) {
api, repo, _ := seedLoginPasskeyAPI(t)
api.ClientIPHeader = "CF-Connecting-IP"
eh := api.ExternalHandler()
from := func(ip string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`,
map[string]string{"Content-Type": "application/json", "CF-Connecting-IP": ip})
}
for i := 1; i <= 32; i++ {
if w := from(fmt.Sprintf("2001:db8:7:%x::1", i)); w.Code != http.StatusOK {
t.Fatalf("begin %d: code = %d, want 200 (%s)", i, w.Code, w.Body.String())
}
if w := begin(eh, "[email protected]"); w.Code != http.StatusTooManyRequests {
t.Fatalf("recased re-begin: code = %d, want 429 (key must be lowercased)", w.Code)
}
w := from("2001:db8:7:ff::1")
if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "too_many_challenges" {
t.Fatalf("33rd begin from the /48: code = %d body %s, want 429 too_many_challenges", w.Code, w.Body.String())
}
if len(repo.passkeyChallenges) != 32 {
t.Errorf("stashed = %d, want 32", len(repo.passkeyChallenges))
}
if w := from("2001:db8:8::1"); w.Code != http.StatusOK {
t.Fatalf("begin from another /48: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := from("203.0.113.9"); w.Code != http.StatusOK {
t.Fatalf("begin from an IPv4 address: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
}
// TestPasskeyChallengeCanonicalForm pins that the stored and the signed challenge compare
// equal whatever padding each side used: go-webauthn accepts padded and unpadded
// base64url, and a browser's clientDataJSON may arrive either way.
func TestPasskeyChallengeCanonicalForm(t *testing.T) {
if got, err := optionsChallenge(json.RawMessage(`{"publicKey":{"challenge":"ZmFrZQ=="}}`)); err != nil || got != "ZmFrZQ" {
t.Errorf("optionsChallenge(padded) = %q, %v; want ZmFrZQ", got, err)
}
padded := base64.URLEncoding.EncodeToString([]byte(`{"challenge":"ZmFrZQ"}`)) // 22 bytes: ends in "=="
unpadded := base64.RawURLEncoding.EncodeToString([]byte(`{"challenge":"ZmFrZQ=="}`))
for name, cd := range map[string]string{"padded clientDataJSON": padded, "padded challenge": unpadded} {
got, err := assertionChallenge(json.RawMessage(`{"response":{"clientDataJSON":"` + cd + `"}}`))
if err != nil || got != "ZmFrZQ" {
t.Errorf("%s: assertionChallenge = %q, %v; want ZmFrZQ", name, got, err)
}
})
}
for name, a := range map[string]string{
"no response": `{"id":"cred-1"}`,
"clientDataJSON junk": `{"response":{"clientDataJSON":"!!"}}`,
"empty challenge": `{"response":{"clientDataJSON":"` + base64.RawURLEncoding.EncodeToString([]byte(`{"challenge":""}`)) + `"}}`,
} {
if got, err := assertionChallenge(json.RawMessage(a)); err == nil {
t.Errorf("%s: assertionChallenge = %q, want an error", name, got)
}
}
}
// TestPasskeyLoginAllowsStaff pins the deliberate contrast with the email door: that door
@@ -434,7 +508,7 @@ func TestPasskeyLoginAllowsStaff(t *testing.T) {
t.Fatalf("begin for staff: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
w := do(eh, "POST", "/api/v1/auth/passkey/login/finish",
`{"email":"[email protected]","assertion":{"id":"cred-a1","type":"public-key"}}`, jsonHeader)
`{"email":"[email protected]","assertion":`+loginAssertion("cred-a1", "YXNzZXJ0")+`}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("finish for staff: code = %d, want 200 (passkey admits staff) (%s)", w.Code, w.Body.String())
}
@@ -472,8 +546,7 @@ func TestPasskeyLoginFinishCloneRejected(t *testing.T) {
plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL))
eh := api.ExternalHandler()
w := do(eh, "POST", "/api/v1/auth/passkey/login/finish",
`{"email":"[email protected]","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
w := do(eh, "POST", "/api/v1/auth/passkey/login/finish", finishBody("[email protected]", "YXNzZXJ0"), jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
t.Fatalf("clone finish: code = %d body %s, want 400 passkey_login_invalid (opaque refusal)", w.Code, w.Body.String())
+196 -55
View File
@@ -823,6 +823,38 @@ func (p *PGRepo) CreateEmailOTP(ctx context.Context, id, userID, email, codeHash
return tx.Commit()
}
// AddLoginEmailOTP stores a code for a pre-session login door beside the codes
// already mailed for (user, purpose), keeping the newest otpLiveLoginCodes live:
// it drops every unconsumed code outside the newest otpLiveLoginCodes-1 unexpired
// ones (so expired codes go too), then inserts. It never cancels a code just because another start came in, so knowing
// an address is not enough to keep its owner from holding a working code
// (ConsumeLoginEmailOTP accepts any live one).
func (p *PGRepo) AddLoginEmailOTP(ctx context.Context, id, userID, email, codeHash, purpose string, now, expiresAt time.Time) error {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
if _, err := tx.ExecContext(ctx,
`DELETE FROM email_otps
WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL
AND id NOT IN (
SELECT id FROM email_otps
WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > $3
ORDER BY created_at DESC, id DESC LIMIT $4)`,
userID, purpose, now, otpLiveLoginCodes-1); err != nil {
return fmt.Errorf("trim live login codes: %w", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO email_otps (id, user_id, email, code_hash, purpose, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
id, userID, email, codeHash, purpose, expiresAt); err != nil {
return fmt.Errorf("insert otp: %w", err)
}
return tx.Commit()
}
// VerifyEmailOTP redeems the newest live code for (user, purpose) in one
// transaction (spec §B2). The row is taken FOR UPDATE so a concurrent verify of the
// same code cannot double-spend it. The branch order is deliberate: expiry and the
@@ -870,7 +902,7 @@ func (p *PGRepo) VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash s
return "", ErrOTPLocked
}
if storedHash != codeHash {
return "", chargeOTPMismatch(ctx, tx, id, userID, purpose, now)
return "", chargeOTPMismatch(ctx, tx, userID, purpose, now)
}
// A DIFFERENT account may not also prove this address: the pre-session login
@@ -1280,25 +1312,109 @@ func (p *PGRepo) ConsumePasskeyChallengeByUser(ctx context.Context, userID, purp
return sessionData, nil
}
// maxLiveChallengesPerSource bounds the live login challenges one source (an IPv4
// address or IPv6 /48, see challengeSource) holds in each login store. A ceremony
// takes seconds and a challenge lives passkeyChallengeTTL, so a network with a few
// dozen people signing in at once stays well inside it, while a flood of begins
// fills its own allowance and leaves the other networks their sign-ins. The count
// and the insert are not serialised, so a burst of truly concurrent begins can pass
// it together; the per-source token bucket in front of the door caps that burst.
const maxLiveChallengesPerSource = 32
// AddPasskeyLoginChallenge stores an email-first login ceremony beside the ones
// already live for (user, purpose); finish finds it by the challenge the browser
// signed (ConsumePasskeyLoginChallenge), so a begin by anyone who knows the address
// never cancels its owner's ceremony. It reaps the account's spent login rows,
// refuses with ErrTooManyPasskeyChallenges once source holds
// maxLiveChallengesPerSource live login challenges, then inserts.
func (p *PGRepo) AddPasskeyLoginChallenge(ctx context.Context, id, userID, purpose, source, challenge string, sessionData []byte, now, expiresAt time.Time) error {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
if _, err := tx.ExecContext(ctx,
`DELETE FROM webauthn_challenges
WHERE user_id = $1 AND purpose = $2 AND (expires_at <= $3 OR consumed_at IS NOT NULL)`,
userID, purpose, now); err != nil {
return fmt.Errorf("reap login challenges: %w", err)
}
var fromSource int
if err := tx.QueryRowContext(ctx,
`SELECT count(*) FROM webauthn_challenges
WHERE source = $1 AND consumed_at IS NULL AND expires_at > $2`,
source, now).Scan(&fromSource); err != nil {
return fmt.Errorf("count login challenges: %w", err)
}
if fromSource >= maxLiveChallengesPerSource {
return ErrTooManyPasskeyChallenges
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO webauthn_challenges (id, user_id, purpose, session_data, expires_at, challenge, source)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
id, userID, purpose, sessionData, expiresAt, challenge, source); err != nil {
return fmt.Errorf("insert login challenge: %w", err)
}
return tx.Commit()
}
// ConsumePasskeyLoginChallenge redeems the live login challenge of (user, purpose)
// whose challenge is the one the browser signed, single-use: the row is taken FOR
// UPDATE, expiry is checked against now, consumed_at is stamped, and the stashed
// SessionData is returned. No such live row → ErrPasskeyChallengeInvalid.
func (p *PGRepo) ConsumePasskeyLoginChallenge(ctx context.Context, userID, purpose, challenge string, now time.Time) ([]byte, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
var (
id string
sessionData []byte
expiresAt time.Time
)
switch err := tx.QueryRowContext(ctx,
`SELECT id, session_data, expires_at FROM webauthn_challenges
WHERE user_id = $1 AND purpose = $2 AND challenge = $3 AND consumed_at IS NULL
FOR UPDATE`,
userID, purpose, challenge).Scan(&id, &sessionData, &expiresAt); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrPasskeyChallengeInvalid
case err != nil:
return nil, err
}
if !expiresAt.After(now) {
return nil, ErrPasskeyChallengeInvalid
}
if _, err := tx.ExecContext(ctx,
`UPDATE webauthn_challenges SET consumed_at = $2 WHERE id = $1`, id, now); err != nil {
return nil, fmt.Errorf("consume login challenge: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return sessionData, nil
}
// ---- discoverable ("usernameless") passkey login (task #40, migration 0013) ----
// maxLiveDiscoverableChallenges hard-bounds the non-user-keyed discoverable-login challenge
// store. webauthn_challenges self-bounds via a per-(user,purpose) supersede; a from-zero begin
// has no such key, so the table is capped: once this many LIVE (unexpired, unconsumed) rows
// 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. 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
// store: once this many LIVE (unexpired, unconsumed) rows exist, a new begin is refused
// (ErrTooManyPasskeyChallenges → 429). Each source is held to maxLiveChallengesPerSource
// first, so filling the store takes this many / 32 distinct networks; a row is a few hundred
// bytes, so the ceiling is a few MB.
const maxLiveDiscoverableChallenges = 16384
// CreateDiscoverableChallenge stashes a discoverable-login ceremony under an opaque handle,
// bounding the table in one transaction (see the Repo interface for the full contract). It
// reaps expired/consumed rows first — the non-user-keyed analog of CreatePasskeyChallenge's
// supersede — then refuses over the cap rather than inserting. Because the reap ran first, the
// COUNT is exactly the live-row count, so the cap bounds an adversarial begin-flood (which a
// reap alone cannot: a burst inside the TTL leaves every fresh row live).
func (p *PGRepo) CreateDiscoverableChallenge(ctx context.Context, id string, sessionData []byte, now, expiresAt time.Time) error {
// reaps expired/consumed rows first, then refuses when source already holds
// maxLiveChallengesPerSource live rows or the table holds maxLiveDiscoverableChallenges.
// Because the reap ran first, the counts are exactly the live rows, so the bounds hold under
// an adversarial begin-flood (which a reap alone cannot: a burst inside the TTL leaves every
// fresh row live).
func (p *PGRepo) CreateDiscoverableChallenge(ctx context.Context, id, source string, sessionData []byte, now, expiresAt time.Time) error {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
@@ -1310,18 +1426,19 @@ func (p *PGRepo) CreateDiscoverableChallenge(ctx context.Context, id string, ses
now); err != nil {
return fmt.Errorf("reap discoverable challenges: %w", err)
}
var live int
var live, fromSource int
if err := tx.QueryRowContext(ctx,
`SELECT count(*) FROM webauthn_discoverable_challenges`).Scan(&live); err != nil {
`SELECT count(*), count(*) FILTER (WHERE source = $1) FROM webauthn_discoverable_challenges`,
source).Scan(&live, &fromSource); err != nil {
return fmt.Errorf("count discoverable challenges: %w", err)
}
if live >= maxLiveDiscoverableChallenges {
return ErrTooManyDiscoverableChallenges
if fromSource >= maxLiveChallengesPerSource || live >= maxLiveDiscoverableChallenges {
return ErrTooManyPasskeyChallenges
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO webauthn_discoverable_challenges (id, session_data, expires_at)
VALUES ($1, $2, $3)`,
id, sessionData, expiresAt); err != nil {
`INSERT INTO webauthn_discoverable_challenges (id, session_data, expires_at, source)
VALUES ($1, $2, $3, $4)`,
id, sessionData, expiresAt, source); err != nil {
return fmt.Errorf("insert discoverable challenge: %w", err)
}
return tx.Commit()
@@ -2230,14 +2347,19 @@ func (p *PGRepo) UserByEmail(ctx context.Context, email string) (*StaffUser, err
return &u, nil
}
// ConsumeLoginEmailOTP redeems the live code for the PRE-SESSION email login door
// with the SAME lifecycle as VerifyEmailOTP (FOR UPDATE, expiry + attempt cap
// before the hash compare, a mismatch charges one attempt without consuming) but
// with NO identity side-effects: it neither writes users.email nor runs the
// verified-email uniqueness guard — login already resolved the userID via
// UserByEmail, which requires email_verified, so the address is settled. Errors
// are exactly ErrOTPInvalid / ErrOTPLocked (ErrEmailTaken is structurally
// impossible here).
// ConsumeLoginEmailOTP redeems a live code for the PRE-SESSION login doors (and
// the step-up doors, which keep one code live) in one transaction. Every live,
// unexpired code for (user, purpose) is taken FOR UPDATE, since a login door keeps
// several (AddLoginEmailOTP). The branch order matches VerifyEmailOTP: nothing live
// is ErrOTPInvalid; the account lock comes next; when every live code has used up
// its attempts the answer is ErrOTPLocked; a code matching none charges one attempt
// to each code still open and one failure to the account, and consumes nothing. A
// match consumes that code and every other live one for (user, purpose): the
// sign-in they were mailed for has happened. There are no identity side-effects:
// it neither writes users.email nor runs the verified-email uniqueness guard —
// login already resolved the userID via UserByEmail, which requires
// email_verified, so the address is settled. Errors are exactly ErrOTPInvalid /
// ErrOTPLocked / *OTPAccountLockedError.
func (p *PGRepo) ConsumeLoginEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) error {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
@@ -2245,25 +2367,37 @@ func (p *PGRepo) ConsumeLoginEmailOTP(ctx context.Context, userID, purpose, code
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
var (
id string
storedHash string
attempts int
expiresAt time.Time
)
switch err := tx.QueryRowContext(ctx,
`SELECT id, code_hash, attempts, expires_at FROM email_otps
WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL
ORDER BY created_at DESC LIMIT 1 FOR UPDATE`,
userID, purpose).Scan(&id, &storedHash, &attempts, &expiresAt); {
case errors.Is(err, sql.ErrNoRows):
// Nothing live: never minted, already consumed, or superseded.
return ErrOTPInvalid
case err != nil:
rows, err := tx.QueryContext(ctx,
`SELECT code_hash, attempts FROM email_otps
WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > $3
FOR UPDATE`,
userID, purpose, now)
if err != nil {
return err
}
if !expiresAt.After(now) {
var live, open int
matched := false
for rows.Next() {
var (
storedHash string
attempts int
)
if err := rows.Scan(&storedHash, &attempts); err != nil {
rows.Close()
return err
}
live++
if attempts < otpMaxAttempts {
open++
matched = matched || storedHash == codeHash
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if live == 0 {
// Nothing live: never minted, already consumed, trimmed, or expired.
return ErrOTPInvalid
}
if until, err := otpLockedUntil(ctx, tx, userID, purpose, now, true); err != nil {
@@ -2271,15 +2405,17 @@ func (p *PGRepo) ConsumeLoginEmailOTP(ctx context.Context, userID, purpose, code
} else if !until.IsZero() {
return &OTPAccountLockedError{Until: until}
}
if attempts >= otpMaxAttempts {
if open == 0 {
return ErrOTPLocked
}
if storedHash != codeHash {
return chargeOTPMismatch(ctx, tx, id, userID, purpose, now)
if !matched {
return chargeOTPMismatch(ctx, tx, userID, purpose, now)
}
if _, err := tx.ExecContext(ctx,
`UPDATE email_otps SET consumed_at = $2 WHERE id = $1`, id, now); err != nil {
`UPDATE email_otps SET consumed_at = $3
WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL`,
userID, purpose, now); err != nil {
return fmt.Errorf("consume otp: %w", err)
}
return tx.Commit()
@@ -2325,12 +2461,17 @@ func otpLockEnd(windowStart time.Time, failures int, now time.Time) time.Time {
return end
}
// chargeOTPMismatch records one wrong guess against the code and the account
// budget, commits, and returns what the caller should answer: ErrOTPInvalid, or
// the lock this guess just tripped. A window that has ended starts over at 1.
func chargeOTPMismatch(ctx context.Context, tx *sql.Tx, codeID, userID, purpose string, now time.Time) error {
// chargeOTPMismatch records one wrong guess against every live code of (user,
// purpose) that still has attempts left and against the account budget, commits,
// and returns what the caller should answer: ErrOTPInvalid, or the lock this guess
// just tripped. The caller holds those codes FOR UPDATE, so the charged set is the
// set it compared. A window that has ended starts over at 1.
func chargeOTPMismatch(ctx context.Context, tx *sql.Tx, userID, purpose string, now time.Time) error {
if _, err := tx.ExecContext(ctx,
`UPDATE email_otps SET attempts = attempts + 1 WHERE id = $1`, codeID); err != nil {
`UPDATE email_otps SET attempts = attempts + 1
WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL
AND expires_at > $3 AND attempts < $4`,
userID, purpose, now, otpMaxAttempts); err != nil {
return fmt.Errorf("record otp attempt: %w", err)
}
var (
+17
View File
@@ -264,3 +264,20 @@ func sourceKey(ip netip.Addr) string {
return p.String()
}
}
// challengeSource is the network a passkey login challenge is counted against
// (maxLiveChallengesPerSource): IPv4 per host, IPv6 per /48. A /48 is what one site
// is handed, so its holder cannot spread a flood of begins over the 65,536 /64s the
// auth-door bucket would see as separate callers. An unparseable address shares one
// key.
func challengeSource(ip netip.Addr) string {
switch {
case !ip.IsValid():
return "unknown"
case ip.Is4():
return ip.String()
default:
p, _ := ip.Prefix(48)
return p.String()
}
}
+20
View File
@@ -127,6 +127,26 @@ func TestSourceKeyGroupsIPv6By64(t *testing.T) {
}
}
// TestChallengeSource pins how passkey login challenges are grouped by network: an
// IPv4 address stands alone, an IPv6 address counts toward its /48 (one site's
// allocation, so hopping /64s inside it stays one source).
func TestChallengeSource(t *testing.T) {
cases := []struct{ in, want string }{
{"203.0.113.9", "203.0.113.9"},
{"2001:db8:7:1::1", "2001:db8:7::/48"},
{"2001:db8:7:ffff:1:2:3:4", "2001:db8:7::/48"},
{"2001:db8:8::1", "2001:db8:8::/48"},
}
for _, c := range cases {
if got := challengeSource(netip.MustParseAddr(c.in)); got != c.want {
t.Errorf("challengeSource(%s) = %q, want %q", c.in, got, c.want)
}
}
if got := challengeSource(netip.Addr{}); got != "unknown" {
t.Errorf("challengeSource(invalid) = %q, want unknown", got)
}
}
func TestAuthDoorsThrottlePerClientAddress(t *testing.T) {
api, _, _ := seedLoginEmailAPI(t)
api.AuthDoorLimit = RateLimit{Burst: 3, PerMinute: 3}
+37 -14
View File
@@ -375,6 +375,13 @@ type Repo interface {
// outstanding code per purpose — a re-request invalidates the earlier mail.
// expiresAt is the API clock + TTL so expiry is driven by one authoritative clock.
CreateEmailOTP(ctx context.Context, id, userID, email, codeHash, purpose string, expiresAt time.Time) error
// AddLoginEmailOTP persists a code for a PRE-SESSION login door (email login,
// op-login) beside the codes already mailed for (userID, purpose): it keeps the
// newest otpLiveLoginCodes live, dropping live codes that expired at now and the
// oldest beyond the allowance. Unlike CreateEmailOTP it never cancels a code
// because another start came in — anyone who knows an address can start a login
// for it, and ConsumeLoginEmailOTP accepts any live code.
AddLoginEmailOTP(ctx context.Context, id, userID, email, codeHash, purpose string, now, expiresAt time.Time) error
// VerifyEmailOTP redeems the newest live code for (userID, purpose) against
// codeHash, atomically (spec §B2). No live code, an expired one, or a consumed
// one → ErrOTPInvalid; an exhausted attempt budget → ErrOTPLocked; a spent
@@ -398,10 +405,13 @@ type Repo interface {
// address is stored unverified for a later Settings/SMTP flow to verify. An unknown
// userID returns ErrNotFound.
SetUserEmail(ctx context.Context, userID, email string) error
// ConsumeLoginEmailOTP redeems the newest live code for (userID, purpose) against
// codeHash for the PRE-SESSION email LOGIN door, with the SAME code lifecycle as
// VerifyEmailOTP (FOR UPDATE, expiry+lockout before hash compare, mismatch charges
// one attempt without consuming) but with NO identity side-effects: it neither
// ConsumeLoginEmailOTP redeems a code for (userID, purpose) against codeHash for
// the PRE-SESSION login doors and the step-up doors. Every live (unconsumed,
// unexpired at now) code is a candidate, since a login door keeps several. The
// lifecycle matches VerifyEmailOTP (FOR UPDATE, lockout before hash compare; all
// live codes out of attempts → ErrOTPLocked; a mismatch charges one attempt to each
// code still open plus one account failure, consuming nothing), and a match spends
// every live code of (userID, purpose). It has NO identity side-effects: it neither
// writes users.email nor runs the verified-email uniqueness guard. Login resolved
// userID via UserByEmail, which already requires email_verified, so the address is
// settled — re-proving control of a code this session must not re-touch the row.
@@ -464,19 +474,32 @@ type Repo interface {
// returns the stashed SessionData so finish can validate the attestation against
// it. No live challenge → ErrPasskeyChallengeInvalid. Single-use: a second finish
// for the same ceremony finds nothing live and fails. now is the API clock so
// expiry is testable. Bound to user_id — enrollment and username-first login both
// know the principal at begin; the usernameless from-zero door instead uses the
// non-user-keyed pair below.
// expiry is testable. Bound to user_id — enrollment and step-up know the principal
// at begin and only its holder can begin one; the public login doors use the
// challenge-matched and non-user-keyed pairs below.
ConsumePasskeyChallengeByUser(ctx context.Context, userID, purpose string, now time.Time) (sessionData []byte, err error)
// AddPasskeyLoginChallenge persists an email-first passkey LOGIN ceremony for
// (userID, purpose) beside the ones already live, recording challenge (the
// canonical base64url challenge handed to the browser) and source (the caller's
// network, see challengeSource). Anyone who knows an address can begin a login
// for it, so a begin never cancels another; it reaps the account's expired or
// consumed login rows and refuses with ErrTooManyPasskeyChallenges once source
// holds maxLiveChallengesPerSource live login challenges.
AddPasskeyLoginChallenge(ctx context.Context, id, userID, purpose, source, challenge string, sessionData []byte, now, expiresAt time.Time) error
// ConsumePasskeyLoginChallenge redeems the live login challenge of (userID,
// purpose) whose challenge is the one the browser signed, atomically and
// single-use, returning its SessionData. No such live row →
// ErrPasskeyChallengeInvalid.
ConsumePasskeyLoginChallenge(ctx context.Context, userID, purpose, challenge string, now time.Time) (sessionData []byte, err error)
// CreateDiscoverableChallenge persists a DISCOVERABLE ("usernameless") login ceremony
// (task #40, migration 0013), keyed by an opaque server-minted handle id — NOT a user,
// since a from-zero begin has no principal. In one transaction it reaps expired/consumed
// rows (the non-user-keyed analog of CreatePasskeyChallenge's supersede) and then, if the
// live count is at the hard cap, refuses with ErrTooManyDiscoverableChallenges rather than
// inserting — the cap, not the reap, bounds an adversarial begin-flood, since a burst
// inside the TTL leaves every fresh row live. now and expiresAt are both the API clock
// (now drives the reap; expiresAt = now + TTL drives liveness).
CreateDiscoverableChallenge(ctx context.Context, id string, sessionData []byte, now, expiresAt time.Time) error
// since a from-zero begin has no principal — and tagged with source (the caller's
// network, see challengeSource). In one transaction it reaps expired/consumed rows and
// then refuses with ErrTooManyPasskeyChallenges, rather than inserting, when source
// already holds maxLiveChallengesPerSource live rows or the table holds
// maxLiveDiscoverableChallenges. now and expiresAt are both the API clock (now drives
// the reap; expiresAt = now + TTL drives liveness).
CreateDiscoverableChallenge(ctx context.Context, id, source string, sessionData []byte, now, expiresAt time.Time) error
// ConsumeDiscoverableChallenge redeems the discoverable challenge under handle id,
// atomically and single-use (mirrors ConsumePasskeyChallengeByUser without the user key):
// it takes the row FOR UPDATE, checks expiry against now, stamps consumed_at, and returns