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
@@ -466,6 +466,26 @@ func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled
|
||||
return err
|
||||
}
|
||||
|
||||
// ConsumeTOTPStep records a just-accepted TOTP step and reports whether it was
|
||||
// fresh. It answers false when the step has already been spent — the same code, or
|
||||
// an earlier one, being presented a second time.
|
||||
//
|
||||
// The guard is in the statement, not in a read-then-write: two logins racing with
|
||||
// the same stolen code must not both find the old value and both proceed. The row
|
||||
// moves forward only when the new step is strictly greater, so exactly one of them
|
||||
// updates a row and the other sees nothing change.
|
||||
func (db *DB) ConsumeTOTPStep(ctx context.Context, userID string, step int64) (bool, error) {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET totp_last_step = ? WHERE id = ?
|
||||
AND (totp_last_step IS NULL OR totp_last_step < ?)`,
|
||||
step, userID, step)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// InboxID returns the user's Inbox project.
|
||||
func (db *DB) InboxID(ctx context.Context, userID string) (string, error) {
|
||||
var id string
|
||||
|
||||
Reference in New Issue
Block a user