Unverified Commit 47573533 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 084ba1ed
Loading
Loading
Loading
Loading
+26 −18
Changes for docs/openapi.yaml: 26 added lines, 18 removed lines.
Original line number Diff line number Diff line
@@ -2128,8 +2128,11 @@ paths:
        matching challenge is stashed server-side and redeemed by finish. Mounted
        Public (no prior principal) and gated on local_auth_enabled. An unknown
        address and a known account with no enrolled passkey both return the SAME 400
        no_passkey, so the door is not an existence oracle; a per-recipient cooldown
        (shared shape with the email-OTP and op-login doors) throttles probing.
        no_passkey, so the door is not an existence oracle; the per-address sign-in
        rate limit bounds probing. Each begin stashes a ceremony of its own beside the
        account's other live ones, so a begin by anyone who knows the address never
        cancels its owner's. One network (an IPv4 address or IPv6 /48) holds at most 32
        live login challenges (429 too_many_challenges past that).
      x-felis-face: [external]
      x-felis-tier: public
      security: []
@@ -2171,7 +2174,7 @@ paths:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: >-
            A passkey login for this recipient was started too recently (otp_resend_cooldown);
            This network already holds 32 live passkey login challenges (too_many_challenges);
            or this client address called the sign-in doors too often (rate_limited, with Retry-After).
          content:
            application/json:
@@ -2190,12 +2193,12 @@ paths:
      description: >-
        Second leg of the public passkey door: the caller returns the email (to
        re-select the account) and the raw navigator.credentials.get() assertion. The
        stashed login challenge is consumed atomically and the assertion is verified
        against it; on success a host-only felis_session cookie is minted. Both players
        live login challenge whose value the assertion signed (response.clientDataJSON)
        is consumed atomically and the assertion is verified against it; on success a host-only felis_session cookie is minted. Both players
        and staff may log in this way — a passkey is a two-factor authenticator
        (possession + user verification), strong enough to stand alone without the
        in-game approval op-login requires. Every failure mode (unknown address, no
        live challenge, expired challenge, bad assertion) collapses into one uniform
        live challenge for the signed value, expired challenge, bad assertion) collapses into one uniform
        passkey_login_invalid, so the door reveals nothing.
      x-felis-face: [external]
      x-felis-tier: public
@@ -2266,10 +2269,10 @@ paths:
        credential it holds for this RP and the account is revealed only by the
        userHandle inside the signed assertion at finish. The challenge cannot be
        user-keyed, so it is stashed under login_id in a non-user-keyed store and echoed
        back at finish. Mounted Public and gated on local_auth_enabled. There is no
        recipient or principal to key a per-caller cooldown on, so one client is bounded
        by the per-address sign-in rate limit (429 rate_limited) and the table by a hard
        global cap on live challenges (429 too_many_challenges). Inert for a credential until its owner
        back at finish. Mounted Public and gated on local_auth_enabled. One client is
        bounded by the per-address sign-in rate limit (429 rate_limited), one network
        (an IPv4 address or IPv6 /48) to 32 live challenges, and the table by a hard
        global cap of 16384 (both 429 too_many_challenges). Inert for a credential until its owner
        enrolls a resident passkey; email-OTP and username-first passkey remain the
        fallbacks, so no authenticator is ever locked out.
      x-felis-face: [external]
@@ -2315,9 +2318,9 @@ paths:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: >-
            Too many discoverable logins are in flight server-wide (too_many_challenges;
            the cap is global, so no per-recipient signal leaks); or this client address
            called the sign-in doors too often (rate_limited, with Retry-After).
            This network already holds 32 live discoverable challenges, or the store is at
            its global cap (too_many_challenges); or this client address called the
            sign-in doors too often (rate_limited, with Retry-After).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
@@ -2413,6 +2416,10 @@ paths:
        purpose. An address with no account returns the SAME 202 with no code minted,
        and the per-recipient cooldown is kept on that path too, so probing reveals
        nothing (existence is learnt only at the sanctioned /auth/options oracle).
        One code is mailed per recipient per minute: a start inside that window gets
        the same 202 (expires_at of the live code) and mails nothing. A start never
        cancels the codes already mailed; the three newest live codes all work, and
        signing in with one spends the rest.
        An account that spent its daily wrong-code budget (10 per 24h, across every
        code) also gets the same 202 and no mail until the window ends. Gated on
        local_auth_enabled.
@@ -2458,8 +2465,7 @@ paths:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: >-
            A code for this recipient was requested too recently (otp_resend_cooldown);
            or this client address called the sign-in doors too often (rate_limited, with Retry-After);
            This client address called the sign-in doors too often (rate_limited, with Retry-After);
            or the install-wide mail budget is spent (mail_rate_limited, with Retry-After).
          content:
            application/json:
@@ -2539,7 +2545,10 @@ paths:
        op_login purpose, returning the request handle the browser polls. A non-staff
        or unknown address gets the SAME 202 with a random, non-persisted handle and no
        mail, so this never becomes a staff-enumeration oracle. A staff account that
        spent its daily wrong-code budget gets the same neutral 202. Gated on
        spent its daily wrong-code budget gets the same neutral 202. One code is mailed
        per recipient per minute: a staff start inside that window opens a real request
        but mails nothing, and the code already in the inbox finishes it. A start never
        cancels the codes already mailed (the three newest live codes all work). Gated on
        local_auth_enabled.
      x-felis-face: [external]
      x-felis-tier: public
@@ -2583,8 +2592,7 @@ paths:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: >-
            A code for this recipient was requested too recently (otp_resend_cooldown);
            or this client address called the sign-in doors too often (rate_limited, with Retry-After);
            This client address called the sign-in doors too often (rate_limited, with Retry-After);
            or the install-wide mail budget is spent (mail_rate_limited, with Retry-After).
          content:
            application/json:
+3 −2
Changes for internal/api/api.go: 3 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for internal/api/api_test.go: 126 added lines, 28 removed lines.
Original line number Diff line number Diff line
@@ -83,6 +83,9 @@ type fakeRepo struct {
	// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
	// newest live (user, purpose) just as the PG query does.
	otps map[string]*fakeEmailOTP
	// otpSeq orders codes by insertion (the PG created_at): a frozen test clock mints
	// several codes at one instant, so time cannot tell the oldest apart.
	otpSeq int
	// otpBudget mirrors otp_failure_windows, keyed user|purpose.
	otpBudget map[string]*fakeOTPBudget
	// op-login requests (spec §B op-login). opLogins mirrors op_login_requests keyed
@@ -140,6 +143,9 @@ type fakePasskeyChallenge struct {
	expiresAt   time.Time
	consumed    bool
	createdAt   time.Time
	// challenge and source are set on email-first login rows only (migration 0029).
	challenge string
	source    string
}

// fakeDiscoverableChallenge mirrors a webauthn_discoverable_challenges row (task #40): no user
@@ -149,6 +155,7 @@ type fakeDiscoverableChallenge struct {
	sessionData []byte
	expiresAt   time.Time
	consumed    bool
	source      string
}

// fakeDataHold mirrors a player_data_holds row at the granularity the verifiable
@@ -177,10 +184,13 @@ func (f *fakeRepo) OTPLockedUntil(_ context.Context, userID, purpose string, now
	return otpLockEnd(b.windowStart, b.failures, now), nil
}

// chargeOTP mirrors chargeOTPMismatch: one wrong guess on the code and the budget.
func (f *fakeRepo) chargeOTP(live *fakeEmailOTP, now time.Time) error {
	live.attempts++
	key := live.userID + "|" + live.purpose
// chargeOTP mirrors chargeOTPMismatch: one wrong guess on each open code and one on
// the (user, purpose) budget.
func (f *fakeRepo) chargeOTP(open []*fakeEmailOTP, userID, purpose string, now time.Time) error {
	for _, o := range open {
		o.attempts++
	}
	key := userID + "|" + purpose
	b := f.otpBudget[key]
	if b == nil || !b.windowStart.Add(otpFailureWindow).After(now) {
		b = &fakeOTPBudget{windowStart: now}
@@ -206,6 +216,7 @@ type fakeEmailOTP struct {
	expiresAt time.Time
	consumed  bool
	createdAt time.Time
	seq       int
}

// fakeSession mirrors a sessions row: its owner, its expiry, and whether it has
@@ -403,12 +414,42 @@ func (f *fakeRepo) CreateEmailOTP(_ context.Context, id, userID, email, codeHash
			delete(f.otps, k)
		}
	}
	f.otpSeq++
	f.otps[id] = &fakeEmailOTP{
		id: id, userID: userID, email: email, codeHash: codeHash, purpose: purpose,
		expiresAt: expiresAt, createdAt: expiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation
		seq: f.otpSeq,
	}
	return nil
}

// AddLoginEmailOTP mirrors PGRepo.AddLoginEmailOTP: the live codes of (user, purpose)
// that expired at now go, then all but the newest otpLiveLoginCodes-1, and the new
// code joins the rest.
func (f *fakeRepo) AddLoginEmailOTP(_ context.Context, id, userID, email, codeHash, purpose string, now, expiresAt time.Time) error {
	var live []*fakeEmailOTP
	for k, o := range f.otps {
		if o.userID != userID || o.purpose != purpose || o.consumed {
			continue
		}
		if !o.expiresAt.After(now) {
			delete(f.otps, k)
			continue
		}
		live = append(live, o)
	}
	sort.Slice(live, func(i, j int) bool { return live[i].seq > live[j].seq })
	for _, o := range live[min(len(live), otpLiveLoginCodes-1):] {
		delete(f.otps, o.id)
	}
	f.otpSeq++
	f.otps[id] = &fakeEmailOTP{
		id: id, userID: userID, email: email, codeHash: codeHash, purpose: purpose,
		expiresAt: expiresAt, createdAt: now, seq: f.otpSeq,
	}
	return nil
}

func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash string, now time.Time) (string, error) {
	var live *fakeEmailOTP
	for _, o := range f.otps { // newest live (user, purpose)
@@ -432,7 +473,7 @@ func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash s
		return "", ErrOTPLocked
	}
	if live.codeHash != codeHash {
		return "", f.chargeOTP(live, now) // a typo costs an attempt but does not consume the code
		return "", f.chargeOTP([]*fakeEmailOTP{live}, userID, purpose, now) // a typo costs an attempt but does not consume the code
	}
	// A DIFFERENT verified holder of the same address → ErrEmailTaken, code left
	// live — mirrors PGRepo's guard + the users_verified_email_unique index.
@@ -478,6 +519,44 @@ func (f *fakeRepo) CreatePasskeyChallenge(_ context.Context, id, userID, purpose
	}
	return nil
}

// AddPasskeyLoginChallenge / ConsumePasskeyLoginChallenge mirror PGRepo's email-first
// login pair: begin reaps the account's spent login rows, refuses once source holds
// maxLiveChallengesPerSource live login rows, and stores the challenge beside the
// others; consume redeems the live row whose challenge the browser signed.
func (f *fakeRepo) AddPasskeyLoginChallenge(_ context.Context, id, userID, purpose, source, challenge string, sessionData []byte, now, expiresAt time.Time) error {
	fromSource := 0
	for k, c := range f.passkeyChallenges {
		if c.userID == userID && c.purpose == purpose && (c.consumed || !c.expiresAt.After(now)) {
			delete(f.passkeyChallenges, k)
			continue
		}
		if c.source == source && !c.consumed && c.expiresAt.After(now) {
			fromSource++
		}
	}
	if fromSource >= maxLiveChallengesPerSource {
		return ErrTooManyPasskeyChallenges
	}
	f.passkeyChallenges[id] = &fakePasskeyChallenge{
		id: id, userID: userID, purpose: purpose, sessionData: sessionData,
		expiresAt: expiresAt, createdAt: now, challenge: challenge, source: source,
	}
	return nil
}
func (f *fakeRepo) ConsumePasskeyLoginChallenge(_ context.Context, userID, purpose, challenge string, now time.Time) ([]byte, error) {
	for _, c := range f.passkeyChallenges {
		if c.userID != userID || c.purpose != purpose || c.challenge != challenge || c.consumed {
			continue
		}
		if !c.expiresAt.After(now) {
			return nil, ErrPasskeyChallengeInvalid
		}
		c.consumed = true
		return c.sessionData, nil
	}
	return nil, ErrPasskeyChallengeInvalid
}
func (f *fakeRepo) ConsumePasskeyChallengeByUser(_ context.Context, userID, purpose string, now time.Time) ([]byte, error) {
	var live *fakePasskeyChallenge
	for _, c := range f.passkeyChallenges { // newest live (user, purpose)
@@ -496,19 +575,28 @@ func (f *fakeRepo) ConsumePasskeyChallengeByUser(_ context.Context, userID, purp
}

// CreateDiscoverableChallenge / ConsumeDiscoverableChallenge mirror PGRepo's non-user-keyed
// contract (task #40): begin reaps expired/consumed rows then stashes under the opaque handle,
// and consume redeems by handle, single-use, expiry checked. discoverableFull forces the capped
// path so the begin 429 branch is reachable without inserting thousands of rows.
func (f *fakeRepo) CreateDiscoverableChallenge(_ context.Context, id string, sessionData []byte, now, expiresAt time.Time) error {
// contract (task #40): begin reaps expired/consumed rows, refuses once source holds
// maxLiveChallengesPerSource live rows, then stashes under the opaque handle; consume redeems
// by handle, single-use, expiry checked. discoverableFull forces the global cap so the begin
// 429 branch is reachable without inserting thousands of rows.
func (f *fakeRepo) CreateDiscoverableChallenge(_ context.Context, id, source string, sessionData []byte, now, expiresAt time.Time) error {
	if f.discoverableFull {
		return ErrTooManyDiscoverableChallenges
		return ErrTooManyPasskeyChallenges
	}
	fromSource := 0
	for k, c := range f.discoverableChallenges { // reap (DELETE ... expires_at<=now OR consumed_at NOT NULL)
		if c.consumed || !c.expiresAt.After(now) {
			delete(f.discoverableChallenges, k)
			continue
		}
		if c.source == source {
			fromSource++
		}
	f.discoverableChallenges[id] = &fakeDiscoverableChallenge{sessionData: sessionData, expiresAt: expiresAt}
	}
	if fromSource >= maxLiveChallengesPerSource {
		return ErrTooManyPasskeyChallenges
	}
	f.discoverableChallenges[id] = &fakeDiscoverableChallenge{sessionData: sessionData, expiresAt: expiresAt, source: source}
	return nil
}
func (f *fakeRepo) ConsumeDiscoverableChallenge(_ context.Context, id string, now time.Time) ([]byte, error) {
@@ -624,6 +712,9 @@ type fakePasskeyVerifier struct {
	// stashed SessionData round-trips and the existing credentials reach the verifier.
	lastUser    PasskeyUser
	lastSession []byte
	// loginSession, when set, is the SessionData BeginLogin hands out in place of the
	// per-user marker, so a test can tell two live login ceremonies apart at finish.
	loginSession []byte
	// discoverableUserHandle is the userHandle the fake feeds to FinishDiscoverableLogin's
	// resolver, so a handler test drives the userHandle → UserByID → session-mint wiring for a
	// chosen account (or an unknown handle, to exercise the resolve-fails branch).
@@ -657,6 +748,9 @@ func (v *fakePasskeyVerifier) BeginLogin(user PasskeyUser) (json.RawMessage, []b
	if opts == nil {
		opts = json.RawMessage(`{"publicKey":{"challenge":"YXNzZXJ0"}}`)
	}
	if v.loginSession != nil {
		return opts, v.loginSession, nil
	}
	return opts, []byte("login-session:" + user.ID), nil
}

@@ -1473,36 +1567,40 @@ func (f *fakeRepo) UserByEmail(_ context.Context, email string) (*StaffUser, err
	return nil, ErrNotFound
}

// ConsumeLoginEmailOTP mirrors PGRepo.ConsumeLoginEmailOTP: it redeems the newest
// live code for (user, purpose) WITHOUT the identity side-effect (login already
// resolved the userID via UserByEmail, so the address is settled). It charges an
// attempt on a hash mismatch (exactly like VerifyEmailOTP) but never writes
// users.email or runs the verified-email guard. A missing/expired/consumed code →
// ErrOTPInvalid; a mismatch → ErrOTPInvalid too (and costs an attempt without
// consuming); a locked code → ErrOTPLocked; a match → consumed, nil.
// ConsumeLoginEmailOTP mirrors PGRepo.ConsumeLoginEmailOTP: every live (unconsumed,
// unexpired) code of (user, purpose) is a candidate. Nothing live → ErrOTPInvalid;
// the account lock next; every live code out of attempts → ErrOTPLocked; no match →
// one attempt on each open code plus one budget failure, nothing consumed; a match
// spends every live code. No identity side-effect (login already resolved the
// userID via UserByEmail, so the address is settled).
func (f *fakeRepo) ConsumeLoginEmailOTP(_ context.Context, userID, purpose, codeHash string, now time.Time) error {
	var live *fakeEmailOTP
	for _, o := range f.otps { // newest live (user, purpose), mirroring VerifyEmailOTP
		if o.userID != userID || o.purpose != purpose || o.consumed {
	var live, open []*fakeEmailOTP
	matched := false
	for _, o := range f.otps {
		if o.userID != userID || o.purpose != purpose || o.consumed || !o.expiresAt.After(now) {
			continue
		}
		if live == nil || o.createdAt.After(live.createdAt) {
			live = o
		live = append(live, o)
		if o.attempts < otpMaxAttempts {
			open = append(open, o)
			matched = matched || o.codeHash == codeHash
		}
	}
	if live == nil || !live.expiresAt.After(now) {
	if len(live) == 0 {
		return ErrOTPInvalid
	}
	if until, _ := f.OTPLockedUntil(context.Background(), userID, purpose, now); !until.IsZero() {
		return &OTPAccountLockedError{Until: until}
	}
	if live.attempts >= otpMaxAttempts {
	if len(open) == 0 {
		return ErrOTPLocked
	}
	if live.codeHash != codeHash {
		return f.chargeOTP(live, now) // a typo costs an attempt but does not consume the code
	if !matched {
		return f.chargeOTP(open, userID, purpose, now) // a typo costs an attempt but consumes nothing
	}
	for _, o := range live {
		o.consumed = true
	}
	live.consumed = true
	return nil
}

+7 −8
Changes for internal/api/errors.go: 7 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -87,14 +87,13 @@ var (
	// guard and gets a 409 instead of a raw unique-violation 500. Distinct from
	// ErrConflict so the message can name the cause (the email is spoken for).
	ErrEmailTaken = errors.New("email already verified on another account")
	// ErrTooManyDiscoverableChallenges means the non-user-keyed discoverable ("usernameless")
	// login challenge store is at its hard cap of live rows (task #40, migration 0013).
	// Unlike the user-keyed enrollment/login challenges — which self-bound via a per-user
	// supersede — a from-zero begin has no principal to key a fair per-caller limit on, so the
	// table is capped globally and a begin over the cap is refused. Distinct from the other
	// sentinels so the handler answers 429 (a transient "too busy, retry" — the cap self-clears
	// as challenges expire), never a 400 that invites an immediate retry.
	ErrTooManyDiscoverableChallenges = errors.New("too many discoverable login challenges in flight")
	// ErrTooManyPasskeyChallenges means a passkey login begin was refused because too many
	// login challenges are live: the caller's source already holds its allowance
	// (maxLiveChallengesPerSource), or the discoverable store is at its global cap
	// (maxLiveDiscoverableChallenges). Distinct from the other sentinels so the handler
	// answers 429 (a transient "too busy, retry" — both bounds clear as challenges expire),
	// never a 400 that invites an immediate retry.
	ErrTooManyPasskeyChallenges = errors.New("too many passkey login challenges in flight")
	// ErrNotStopped means a world-volume operation was refused because the server is
	// not fully stopped: desiredState is not Stopped, or its pod is still shutting
	// down (phase Stopping) and holds the volume while it saves.
+17 −8
Changes for internal/api/handlers_auth_email.go: 17 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -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
	}
Loading