feat(security): a TOTP code can be used once
RFC 6238 says the verifier must reject a code it has already accepted: a code stays valid for its whole thirty-second window, so one seen in flight — a phishing page, a proxy, over the shoulder — could be replayed until it expired. Tasks now record the counter of the last accepted step (totp_last_step), and a login accepts a code only when its step is strictly greater. The guard is in the UPDATE, not a read-then-write, so two logins racing with the same stolen code cannot both find the old value and both proceed — exactly one moves the row. auth.TOTPStep is VerifyTOTP that also reports which step matched, walking the same one-step window; the code entered a little early or late spends the step it was issued at, not the step it was checked at, so a late-but-valid code is still single-use. Verified end to end: the same code presented to two logins in one window is accepted once and refused the second time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b4038356f0
commit
77994eff09
@@ -218,7 +218,24 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now())
|
||||
step, err := auth.TOTPStep(user.TOTPSecret, req.Code, time.Now())
|
||||
if err == nil {
|
||||
// The code is right; now it must also be unused. A TOTP code stays valid for
|
||||
// its whole window, so one observed in flight — a phishing page, a proxy,
|
||||
// over the shoulder — could otherwise be replayed until it expires. Spending
|
||||
// the step here makes it single-use: a second presentation of the same code,
|
||||
// or an earlier one, finds the step already gone.
|
||||
fresh, cerr := s.db.ConsumeTOTPStep(r.Context(), user.ID, step)
|
||||
if cerr != nil {
|
||||
s.internal(w, r, "consume totp step", cerr)
|
||||
return
|
||||
}
|
||||
if !fresh {
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
||||
"that verification code has already been used")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// A recovery code is accepted in the same field. Making somebody find a
|
||||
// different form while locked out of their account is a poor time to
|
||||
|
||||
Reference in New Issue
Block a user