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

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

No files matched your search

+17 -8
View File
@@ -60,6 +60,11 @@ type loginEmailStartRequest struct {
// no code minted: the response never distinguishes the two, and the reservation is
// kept on that path too so repeated probing of one address is throttled identically
// to repeated sends.
//
// A start never cancels the codes already mailed (AddLoginEmailOTP keeps the newest
// otpLiveLoginCodes live), and a start inside the cooldown answers the same 202 without
// minting: the code mailed moments ago is still good. So anyone who knows an address
// can only add codes to its owner's inbox, never keep the owner from signing in.
func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
@@ -98,8 +103,11 @@ func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) {
lim := a.otpLimiter()
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
"a code was sent recently; wait a moment before requesting another"))
// A start for this address went through less than a cooldown ago, and the code
// it mailed (if the address has an account) is still live. Answer as that start
// did, expiry included, and mail nothing: the owner — or whoever typed the
// address — lands on the code screen and the code already in the inbox works.
writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": emailAt.Add(otpTTL).UTC()})
return
}
committed := false
@@ -109,16 +117,17 @@ func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) {
}
}()
// Compute the expiry once so the neutral (no-account) branch and the real-send
// branch return byte-identical bodies.
expiresAt := a.now().Add(otpTTL)
// Compute the expiry once, from the reservation, so the neutral (no-account)
// branch, the real-send branch and a start inside the window all return
// byte-identical bodies.
expiresAt := emailAt.Add(otpTTL)
u, err := a.Repo.UserByEmail(r.Context(), email)
switch {
case errors.Is(err, ErrNotFound):
// No verified account for this address. Return the same 202 as a real send
// (no code minted) and KEEP the reservation, so probing an unknown address is
// throttled exactly like resending to a known one — the throttle reveals
// (no code minted) and KEEP the reservation, so a probe of an unknown address
// holds the window exactly like a send to a known one — the window reveals
// nothing, and the accepted /auth/options oracle is where existence is learnt.
committed = true
writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()})
@@ -158,7 +167,7 @@ func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) {
// record. The login redeem (ConsumeLoginEmailOTP) never reads or writes this
// address, so the stored casing is authoritative and the row's email snapshot is
// purely for the audit trail.
if err := a.Repo.CreateEmailOTP(r.Context(), id, u.ID, u.Email, otpCodeHash(code), otpPurposeLogin, expiresAt); err != nil {
if err := a.Repo.AddLoginEmailOTP(r.Context(), id, u.ID, u.Email, otpCodeHash(code), otpPurposeLogin, a.now(), expiresAt); err != nil {
writeError(w, r, err)
return
}