fix: enforce single-use TOTP passcodes across all 2FA surfaces

The web 2FA login and password-reset paths validated the passcode and then
wrote LastUsedPasscode in a non-atomic read-check-write sequence, so two
parallel submissions of the same code could each authenticate (TOCTOU). The
Basic-Auth X-Gitea-OTP path never recorded the used passcode at all, letting
a captured code be replayed for its whole validity window.

Add TwoFactor.ValidateAndConsumeTOTP, which validates and atomically marks
the passcode used via a conditional UPDATE (rejecting replays and racing
duplicates), and route the web login, password-reset, and Basic-Auth paths
through it.

Assisted-by: Claude:claude-opus-4-8
This commit is contained in:
Nicolas
2026-06-13 18:38:06 +02:00
parent e99e24cb04
commit c0c11c551c
6 changed files with 83 additions and 18 deletions
+23
View File
@@ -21,6 +21,7 @@ import (
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/pbkdf2"
"xorm.io/builder"
)
//
@@ -118,6 +119,28 @@ func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
return totp.Validate(passcode, secretStr), nil
}
// ValidateAndConsumeTOTP validates the passcode and atomically records it as used so that the
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). It returns false for an
// invalid passcode as well as for a replay, including the case where a concurrent request with
// the same passcode won the race first. All TOTP login surfaces must go through this helper.
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (bool, error) {
ok, err := t.ValidateTOTP(passcode)
if err != nil || !ok {
return false, err
}
// Conditional update: only a row whose stored passcode differs from this one is updated, so a
// replay (or a concurrent duplicate) matches zero rows and is rejected. The row lock taken by
// the UPDATE serializes racing requests, closing the read-validate-write TOCTOU window.
t.LastUsedPasscode = passcode
n, err := db.GetEngine(ctx).ID(t.ID).
Where(builder.Or(builder.IsNull{"last_used_passcode"}, builder.Neq{"last_used_passcode": passcode})).
Cols("last_used_passcode").Update(t)
if err != nil {
return false, err
}
return n == 1, nil
}
// NewTwoFactor creates a new two-factor authentication token.
func NewTwoFactor(ctx context.Context, t *TwoFactor) error {
_, err := db.GetEngine(ctx).Insert(t)
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth_test
import (
"testing"
"time"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
key, err := totp.Generate(totp.GenerateOpts{SecretSize: 40, Issuer: "gitea-test", AccountName: "consume"})
require.NoError(t, err)
tfa := &auth_model.TwoFactor{UID: 1}
require.NoError(t, tfa.SetSecret(key.Secret()))
require.NoError(t, auth_model.NewTwoFactor(t.Context(), tfa))
passcode, err := totp.GenerateCode(key.Secret(), time.Now())
require.NoError(t, err)
// first use of a valid passcode succeeds
ok, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
require.NoError(t, err)
assert.True(t, ok)
// replaying the same passcode is refused, even when still inside the TOTP validity window
reloaded, err := auth_model.GetTwoFactorByUID(t.Context(), tfa.UID)
require.NoError(t, err)
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
require.NoError(t, err)
assert.False(t, ok)
// an invalid passcode is rejected without consuming anything
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), "000000")
require.NoError(t, err)
assert.False(t, ok)
}