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:
kristianwind
2026-08-23 23:34:34 +02:00
co-authored by Claude Opus 5
parent b4038356f0
commit 77994eff09
6 changed files with 161 additions and 1 deletions
+44
View File
@@ -372,6 +372,50 @@ func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) {
}
}
// A TOTP code works once. A code seen in flight and replayed inside its window
// must be refused the second time, or the second factor is only as good as the
// thirty seconds an attacker has to reuse what they captured.
func TestTOTPCodeIsSingleUse(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", map[string]string{
"password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp setup: %d %v", resp.StatusCode, body)
}
secret, _ := body["secret"].(string)
resp, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{
"code": codeFor(t, secret),
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("totp confirm: %d %v", resp.StatusCode, body)
}
// One code, captured once and presented twice.
code := codeFor(t, secret)
ts.do(t, "POST", "/api/v1/auth/logout", nil)
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
if resp.StatusCode != http.StatusOK {
t.Fatalf("first use of the code failed: %d %v", resp.StatusCode, body)
}
// A second login, same code, same window.
ts.do(t, "POST", "/api/v1/auth/logout", nil)
ts.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "kristian@example.dk", "password": "et langt kodeord",
})
resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": code})
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("the same code was accepted twice: %d %v", resp.StatusCode, body)
}
}
// A recovery code is accepted in the same field as a TOTP code, and works once.
func TestRecoveryCodeCompletesLoginOnce(t *testing.T) {
ts := newTestServer(t)