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>
12 lines
715 B
SQL
12 lines
715 B
SQL
-- A TOTP code is meant to be usable once. RFC 6238 says so in as many words: the
|
|
-- verifier must reject a code that has already been accepted, or a code observed in
|
|
-- flight — over the shoulder, off a phishing page, out of a proxy — can be replayed
|
|
-- for the rest of its thirty-second window.
|
|
--
|
|
-- totp_last_step records the counter of the most recently accepted code (Unix time
|
|
-- divided by the period). A login accepts a code only when its step is strictly
|
|
-- greater, so the same code cannot be spent twice and time cannot run backwards.
|
|
-- Null means no code has been accepted yet, which is every account until its owner
|
|
-- next signs in with one.
|
|
ALTER TABLE users ADD COLUMN totp_last_step INTEGER;
|