fix(api): 登录验证码与 passkey 挑战不再被他人的 start 作废,冷却内重复 start 照常 202,登录挑战按来源限量
This commit is contained in:
22 files changed
+1290
-314
No files matched your search
+3
-2
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
//go:build pgint
|
||||
|
||||
package pgint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/api"
|
||||
)
|
||||
|
||||
// ---- login codes and ceremonies that coexist (migration 0029) --------------------
|
||||
|
||||
func liveLoginCodes(t *testing.T, userID, purpose string, now time.Time) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM email_otps
|
||||
WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > $3`,
|
||||
userID, purpose, now).Scan(&n); err != nil {
|
||||
t.Fatalf("count live codes: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// A login start keeps the three newest live codes: a fourth drops the oldest, and
|
||||
// redeeming any live one spends its siblings too.
|
||||
func TestAddLoginEmailOTPKeepsNewestThree(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
u := newUser(t, "user", "otp-keep")
|
||||
purpose := "login_email"
|
||||
addr := "keep-" + suffix(t) + "@example.net"
|
||||
t0 := mustNow().Truncate(time.Second)
|
||||
add := func(hash string, at time.Time) {
|
||||
t.Helper()
|
||||
if err := repo.AddLoginEmailOTP(ctx, "keep-"+suffix(t), u.ID, addr, hash, purpose, at, at.Add(10*time.Minute)); err != nil {
|
||||
t.Fatalf("AddLoginEmailOTP(%s): %v", hash, err)
|
||||
}
|
||||
}
|
||||
add("h1", t0)
|
||||
add("h2", t0.Add(time.Minute))
|
||||
add("h3", t0.Add(2*time.Minute))
|
||||
if n := liveLoginCodes(t, u.ID, purpose, t0.Add(2*time.Minute)); n != 3 {
|
||||
t.Fatalf("live codes after three starts = %d, want 3", n)
|
||||
}
|
||||
add("h4", t0.Add(3*time.Minute))
|
||||
at := t0.Add(3 * time.Minute)
|
||||
if n := liveLoginCodes(t, u.ID, purpose, at); n != 3 {
|
||||
t.Fatalf("live codes after four starts = %d, want 3", n)
|
||||
}
|
||||
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h1", at); !errors.Is(err, api.ErrOTPInvalid) {
|
||||
t.Fatalf("oldest code after a fourth start = %v, want ErrOTPInvalid", err)
|
||||
}
|
||||
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h2", at); err != nil {
|
||||
t.Fatalf("second code = %v, want nil", err)
|
||||
}
|
||||
for _, h := range []string{"h3", "h4"} {
|
||||
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, h, at); !errors.Is(err, api.ErrOTPInvalid) {
|
||||
t.Fatalf("sibling %s after a sign-in = %v, want ErrOTPInvalid", h, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Expired codes leave the allowance: a start after they lapse is the only live one.
|
||||
add("h5", t0.Add(4*time.Minute))
|
||||
add("h6", t0.Add(5*time.Minute))
|
||||
later := t0.Add(20 * time.Minute)
|
||||
add("h7", later)
|
||||
if n := liveLoginCodes(t, u.ID, purpose, later); n != 1 {
|
||||
t.Fatalf("live codes after the others expired = %d, want 1", n)
|
||||
}
|
||||
var unconsumed int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM email_otps WHERE user_id = $1 AND purpose = $2 AND consumed_at IS NULL`,
|
||||
u.ID, purpose).Scan(&unconsumed); err != nil {
|
||||
t.Fatalf("count unconsumed: %v", err)
|
||||
}
|
||||
if unconsumed != 1 {
|
||||
t.Fatalf("unconsumed rows = %d, want 1 (expired codes are deleted, not kept)", unconsumed)
|
||||
}
|
||||
}
|
||||
|
||||
// A wrong guess with several live codes costs each open code one attempt and the
|
||||
// account one failure; when every live code is spent the door answers ErrOTPLocked,
|
||||
// and a newer code still signs in.
|
||||
func TestConsumeLoginEmailOTPAcrossLiveCodes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
u := newUser(t, "user", "otp-multi")
|
||||
purpose := "op_login"
|
||||
addr := "multi-" + suffix(t) + "@example.net"
|
||||
t0 := mustNow().Truncate(time.Second)
|
||||
add := func(hash string, at time.Time) {
|
||||
t.Helper()
|
||||
if err := repo.AddLoginEmailOTP(ctx, "multi-"+suffix(t), u.ID, addr, hash, purpose, at, at.Add(10*time.Minute)); err != nil {
|
||||
t.Fatalf("AddLoginEmailOTP(%s): %v", hash, err)
|
||||
}
|
||||
}
|
||||
add("h-a", t0)
|
||||
add("h-b", t0.Add(time.Minute))
|
||||
at := t0.Add(time.Minute)
|
||||
attempts := func() map[string]int {
|
||||
t.Helper()
|
||||
rows, err := db.Query(`SELECT code_hash, attempts FROM email_otps WHERE user_id = $1 AND purpose = $2`, u.ID, purpose)
|
||||
if err != nil {
|
||||
t.Fatalf("read attempts: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
got := map[string]int{}
|
||||
for rows.Next() {
|
||||
var h string
|
||||
var n int
|
||||
if err := rows.Scan(&h, &n); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
got[h] = n
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-wrong", at); !errors.Is(err, api.ErrOTPInvalid) {
|
||||
t.Fatalf("wrong guess = %v, want ErrOTPInvalid", err)
|
||||
}
|
||||
if got := attempts(); got["h-a"] != 1 || got["h-b"] != 1 {
|
||||
t.Fatalf("attempts after one wrong guess = %v, want h-a:1 h-b:1", got)
|
||||
}
|
||||
for i := 2; i <= 5; i++ {
|
||||
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-wrong", at); !errors.Is(err, api.ErrOTPInvalid) {
|
||||
t.Fatalf("wrong guess %d = %v, want ErrOTPInvalid", i, err)
|
||||
}
|
||||
}
|
||||
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-a", at); !errors.Is(err, api.ErrOTPLocked) {
|
||||
t.Fatalf("right code once every live code is spent = %v, want ErrOTPLocked", err)
|
||||
}
|
||||
var failures int
|
||||
if err := db.QueryRow(`SELECT failures FROM otp_failure_windows WHERE user_id = $1 AND purpose = $2`,
|
||||
u.ID, purpose).Scan(&failures); err != nil {
|
||||
t.Fatalf("read budget row: %v", err)
|
||||
}
|
||||
if failures != 5 {
|
||||
t.Fatalf("account failures = %d, want 5 (one per wrong guess, not one per code)", failures)
|
||||
}
|
||||
|
||||
add("h-c", t0.Add(2*time.Minute))
|
||||
at = t0.Add(2 * time.Minute)
|
||||
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-b", at); !errors.Is(err, api.ErrOTPInvalid) {
|
||||
t.Fatalf("spent code beside a fresh one = %v, want ErrOTPInvalid", err)
|
||||
}
|
||||
if got := attempts(); got["h-c"] != 1 || got["h-a"] != 5 || got["h-b"] != 5 {
|
||||
t.Fatalf("attempts after a guess of a spent code = %v, want h-c:1 and the spent codes left at 5", got)
|
||||
}
|
||||
if err := repo.ConsumeLoginEmailOTP(ctx, u.ID, purpose, "h-c", at); err != nil {
|
||||
t.Fatalf("fresh code = %v, want nil", err)
|
||||
}
|
||||
if n := liveLoginCodes(t, u.ID, purpose, at); n != 0 {
|
||||
t.Fatalf("live codes after a sign-in = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Email-first passkey ceremonies of one account coexist and are redeemed by the
|
||||
// challenge the browser signed; one network holds at most 32 live ones.
|
||||
func TestPasskeyLoginChallengesCoexist(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
u := newUser(t, "user", "pk-login")
|
||||
purpose := "passkey_login"
|
||||
source := "203.0.113." + suffix(t)
|
||||
now := mustNow()
|
||||
add := func(id, source, challenge, session string, expiresAt time.Time) error {
|
||||
return repo.AddPasskeyLoginChallenge(ctx, id+"-"+suffix(t), u.ID, purpose, source, challenge, []byte(session), now, expiresAt)
|
||||
}
|
||||
if err := add("c1", source, "Y2hhbGxlbmdlMQ", "s1", now.Add(5*time.Minute)); err != nil {
|
||||
t.Fatalf("add c1: %v", err)
|
||||
}
|
||||
if err := add("c2", source, "Y2hhbGxlbmdlMg", "s2", now.Add(5*time.Minute)); err != nil {
|
||||
t.Fatalf("add c2: %v", err)
|
||||
}
|
||||
if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "bm9ib2R5", now); !errors.Is(err, api.ErrPasskeyChallengeInvalid) {
|
||||
t.Fatalf("unissued challenge = %v, want ErrPasskeyChallengeInvalid", err)
|
||||
}
|
||||
if sd, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "Y2hhbGxlbmdlMQ", now); err != nil || string(sd) != "s1" {
|
||||
t.Fatalf("earlier ceremony = %q, %v; want s1 (a later begin must not cancel it)", sd, err)
|
||||
}
|
||||
if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "Y2hhbGxlbmdlMQ", now); !errors.Is(err, api.ErrPasskeyChallengeInvalid) {
|
||||
t.Fatalf("replay = %v, want ErrPasskeyChallengeInvalid", err)
|
||||
}
|
||||
if sd, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "Y2hhbGxlbmdlMg", now); err != nil || string(sd) != "s2" {
|
||||
t.Fatalf("later ceremony = %q, %v; want s2", sd, err)
|
||||
}
|
||||
// The same challenge under another purpose is a different door.
|
||||
if err := add("c3", source, "Y2hhbGxlbmdlMw", "s3", now.Add(5*time.Minute)); err != nil {
|
||||
t.Fatalf("add c3: %v", err)
|
||||
}
|
||||
if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, "passkey_register", "Y2hhbGxlbmdlMw", now); !errors.Is(err, api.ErrPasskeyChallengeInvalid) {
|
||||
t.Fatalf("other purpose = %v, want ErrPasskeyChallengeInvalid", err)
|
||||
}
|
||||
if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "Y2hhbGxlbmdlMw", now.Add(6*time.Minute)); !errors.Is(err, api.ErrPasskeyChallengeInvalid) {
|
||||
t.Fatalf("expired ceremony = %v, want ErrPasskeyChallengeInvalid", err)
|
||||
}
|
||||
|
||||
// Other accounts' spent rows from the same source hold no slot: one expired, one
|
||||
// redeemed. Two accounts, since an account's own begin reaps its spent rows.
|
||||
other := newUser(t, "user", "pk-login-other")
|
||||
if err := repo.AddPasskeyLoginChallenge(ctx, "ox-"+suffix(t), other.ID, purpose, source, "ZXhwaXJlZA", []byte("s"), now, now.Add(-time.Second)); err != nil {
|
||||
t.Fatalf("other account's expired begin: %v", err)
|
||||
}
|
||||
third := newUser(t, "user", "pk-login-third")
|
||||
if err := repo.AddPasskeyLoginChallenge(ctx, "oc-"+suffix(t), third.ID, purpose, source, "cmVkZWVtZWQ", []byte("s"), now, now.Add(5*time.Minute)); err != nil {
|
||||
t.Fatalf("third account's begin: %v", err)
|
||||
}
|
||||
if _, err := repo.ConsumePasskeyLoginChallenge(ctx, third.ID, purpose, "cmVkZWVtZWQ", now); err != nil {
|
||||
t.Fatalf("third account's finish: %v", err)
|
||||
}
|
||||
|
||||
// Per-source bound: c3 is still live at now, so 31 more fill the allowance.
|
||||
for i := 0; i < 31; i++ {
|
||||
if err := add("cap", source, "Y2Fw", "s", now.Add(5*time.Minute)); err != nil {
|
||||
t.Fatalf("add %d from the source: %v", i+2, err)
|
||||
}
|
||||
}
|
||||
if err := add("over", source, "b3Zlcg", "s", now.Add(5*time.Minute)); !errors.Is(err, api.ErrTooManyPasskeyChallenges) {
|
||||
t.Fatalf("33rd live challenge from one source = %v, want ErrTooManyPasskeyChallenges", err)
|
||||
}
|
||||
if err := repo.AddPasskeyLoginChallenge(ctx, "x-"+suffix(t), other.ID, purpose, source, "eA", []byte("s"), now, now.Add(5*time.Minute)); !errors.Is(err, api.ErrTooManyPasskeyChallenges) {
|
||||
t.Fatalf("another account's begin from the full source = %v, want ErrTooManyPasskeyChallenges", err)
|
||||
}
|
||||
if err := add("elsewhere", "198.51.100."+suffix(t), "ZWxzZXdoZXJl", "s", now.Add(5*time.Minute)); err != nil {
|
||||
t.Fatalf("begin from another source: %v", err)
|
||||
}
|
||||
// A redeemed ceremony frees its slot.
|
||||
if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, "b3Zlcg", now); !errors.Is(err, api.ErrPasskeyChallengeInvalid) {
|
||||
t.Fatalf("the refused begin left a row: %v", err)
|
||||
}
|
||||
var capID string
|
||||
if err := db.QueryRow(`SELECT challenge FROM webauthn_challenges WHERE user_id = $1 AND source = $2 AND consumed_at IS NULL LIMIT 1`,
|
||||
u.ID, source).Scan(&capID); err != nil {
|
||||
t.Fatalf("pick a live challenge: %v", err)
|
||||
}
|
||||
if _, err := repo.ConsumePasskeyLoginChallenge(ctx, u.ID, purpose, capID, now); err != nil {
|
||||
t.Fatalf("redeem one from the full source: %v", err)
|
||||
}
|
||||
if err := add("after", source, "YWZ0ZXI", "s", now.Add(5*time.Minute)); err != nil {
|
||||
t.Fatalf("begin after one was redeemed = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The usernameless store holds each source to 32 live challenges and the whole
|
||||
// table to 16384.
|
||||
func TestDiscoverableChallengeBounds(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := mustNow()
|
||||
source := "2001:db8:" + suffix(t)[:4] + "::/48"
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM webauthn_discoverable_challenges WHERE source LIKE 'pgint-bulk-%' OR source = $1`, source) //nolint:errcheck
|
||||
})
|
||||
var ids []string
|
||||
for i := 0; i < 32; i++ {
|
||||
id := "disc-" + suffix(t)
|
||||
if err := repo.CreateDiscoverableChallenge(ctx, id, source, []byte("s"), now, now.Add(5*time.Minute)); err != nil {
|
||||
t.Fatalf("create %d: %v", i+1, err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := repo.CreateDiscoverableChallenge(ctx, "disc-over-"+suffix(t), source, []byte("s"), now, now.Add(5*time.Minute)); !errors.Is(err, api.ErrTooManyPasskeyChallenges) {
|
||||
t.Fatalf("33rd from one source = %v, want ErrTooManyPasskeyChallenges", err)
|
||||
}
|
||||
if err := repo.CreateDiscoverableChallenge(ctx, "disc-else-"+suffix(t), "pgint-bulk-else", []byte("s"), now, now.Add(5*time.Minute)); err != nil {
|
||||
t.Fatalf("another source: %v", err)
|
||||
}
|
||||
if _, err := repo.ConsumeDiscoverableChallenge(ctx, ids[0], now); err != nil {
|
||||
t.Fatalf("consume: %v", err)
|
||||
}
|
||||
if err := repo.CreateDiscoverableChallenge(ctx, "disc-after-"+suffix(t), source, []byte("s"), now, now.Add(5*time.Minute)); err != nil {
|
||||
t.Fatalf("after a redeem freed a slot = %v, want nil", err)
|
||||
}
|
||||
|
||||
// Fill the table to its store-wide bound from many sources, none of them full.
|
||||
var live int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM webauthn_discoverable_challenges WHERE consumed_at IS NULL AND expires_at > $1`, now).Scan(&live); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO webauthn_discoverable_challenges (id, session_data, expires_at, source)
|
||||
SELECT 'bulk-' || $1 || '-' || i, '\x00'::bytea, $2, 'pgint-bulk-' || (i % 1000)
|
||||
FROM generate_series(1, $3) AS i`, suffix(t), now.Add(5*time.Minute), 16384-live); err != nil {
|
||||
t.Fatalf("bulk insert: %v", err)
|
||||
}
|
||||
if err := repo.CreateDiscoverableChallenge(ctx, "disc-full-"+suffix(t), "pgint-bulk-fresh", []byte("s"), now, now.Add(5*time.Minute)); !errors.Is(err, api.ErrTooManyPasskeyChallenges) {
|
||||
t.Fatalf("begin with the table full = %v, want ErrTooManyPasskeyChallenges", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Passkey login challenges stop being one-per-account and are bounded per source.
|
||||
--
|
||||
-- An email-first passkey login used to keep one challenge per account: every begin
|
||||
-- deleted the previous one, so anyone who knew an address could cancel its owner's
|
||||
-- ceremony by starting another. A login challenge now lives beside the others and
|
||||
-- finish picks the one whose challenge the browser signed (clientDataJSON carries
|
||||
-- it), so a stranger's begin never touches the owner's.
|
||||
--
|
||||
-- Both login stores are then bounded by where the begins come from: source is the
|
||||
-- caller's IPv4 address or IPv6 /48, and a source holds only so many live login
|
||||
-- challenges at once (maxLiveChallengesPerSource). One network flooding begins
|
||||
-- fills its own allowance and leaves every other network able to sign in.
|
||||
ALTER TABLE webauthn_challenges ADD COLUMN challenge text;
|
||||
ALTER TABLE webauthn_challenges ADD COLUMN source text;
|
||||
ALTER TABLE webauthn_discoverable_challenges ADD COLUMN source text;
|
||||
|
||||
CREATE INDEX webauthn_challenges_source_idx
|
||||
ON webauthn_challenges (source) WHERE source IS NOT NULL;
|
||||
CREATE INDEX webauthn_discoverable_challenges_source_idx
|
||||
ON webauthn_discoverable_challenges (source);
|
||||
Reference in new issue
Block a user