fix(auth): limit failed TOTP attempts per account (#39993)

Gitea accepted unlimited wrong TOTP passcodes, so anyone holding an
account's password could enumerate the 6-digit code in minutes. This
allows 5 failed attempts per account, then refuses TOTP until 15 minutes
after the last one.

- The count lives in the cache, and the global lock spans the check,
validation and counting, so new sessions and concurrent requests cannot
widen it. With Redis, both are shared across instances
- Applies to every TOTP check: the web 2FA page, account recovery and
Basic auth with `X-Gitea-OTP` (API, git, packages)
- Only wrong or replayed passcodes count, server errors do not
- Scratch codes and WebAuthn stay usable as the recovery path while
locked
- Failures log `Failed authentication attempt for <user> from <ip>`,
which the fail2ban filter already matches

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
Sandiyo Christan
2026-11-07 23:01:49 +00:00
committed by GitHub
parent 51879941dc
commit c54ec52f0f
10 changed files with 171 additions and 46 deletions

View File

@@ -12,8 +12,11 @@ import (
"encoding/base64"
"encoding/hex"
"fmt"
"strconv"
"gitea.dev/models/db"
"gitea.dev/modules/cache"
"gitea.dev/modules/globallock"
"gitea.dev/modules/secret"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
@@ -48,6 +51,11 @@ func (err ErrTwoFactorNotEnrolled) Unwrap() error {
return util.ErrNotExist
}
const (
totpMaxAttempts = 5
totpAttemptsTTL = 15 * 60 // seconds, renewed by every failed attempt
)
// TwoFactor represents a two-factor authentication token.
type TwoFactor struct {
ID int64 `xorm:"pk autoincr"`
@@ -120,26 +128,59 @@ func (t *TwoFactor) validateTOTP(passcode string) (bool, error) {
return totp.Validate(passcode, secretStr), nil
}
type TwoFactorResult int
const (
TwoFactorResultValid TwoFactorResult = iota + 1
TwoFactorResultInvalid
TwoFactorResultTooManyAttempts
)
// 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
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). Invalid passcodes and replays
// return TwoFactorResultInvalid; exhausted attempts return TwoFactorResultTooManyAttempts.
// All TOTP login surfaces must go through this helper.
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (result TwoFactorResult, err error) {
if passcode == "" {
// password-only Basic auth clients must not use up the attempts
return TwoFactorResultInvalid, nil
}
// 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
// the lock spans check, validation and counting so that concurrent requests cannot exceed the limit
attemptsKey := fmt.Sprintf("twofa_attempts_%d", t.UID)
err = globallock.LockAndDo(ctx, attemptsKey, func(ctx context.Context) error {
value, _ := cache.GetCache().Get(attemptsKey)
attempts, _ := strconv.Atoi(value)
if attempts >= totpMaxAttempts {
result = TwoFactorResultTooManyAttempts
return nil
}
valid, err := t.validateTOTP(passcode)
if err != nil {
return err
}
if valid {
// 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 err
}
valid = n == 1
}
if !valid {
result = TwoFactorResultInvalid
return cache.GetCache().Put(attemptsKey, strconv.Itoa(attempts+1), totpAttemptsTTL)
}
cache.Remove(attemptsKey)
result = TwoFactorResultValid
return nil
})
return result, err
}
// NewTwoFactor creates a new two-factor authentication token.

View File

@@ -4,11 +4,15 @@
package auth_test
import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
"gitea.dev/modules/cache"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/pquerna/otp/totp"
@@ -31,21 +35,42 @@ func TestTwoFactorLifecycle(t *testing.T) {
require.NoError(t, err)
// first use of a valid passcode succeeds
ok, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
result, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
require.NoError(t, err)
assert.True(t, ok)
assert.Equal(t, auth_model.TwoFactorResultValid, result)
// 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)
result, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
require.NoError(t, err)
assert.False(t, ok)
assert.Equal(t, auth_model.TwoFactorResultInvalid, result)
// an invalid passcode is rejected without consuming anything
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), "000000")
var limited atomic.Int32
var wg sync.WaitGroup
for range 10 {
wg.Go(func() {
result, err := reloaded.ValidateAndConsumeTOTP(t.Context(), "0000000")
assert.NoError(t, err)
if result == auth_model.TwoFactorResultTooManyAttempts {
limited.Add(1)
} else {
assert.Equal(t, auth_model.TwoFactorResultInvalid, result)
}
})
}
wg.Wait()
assert.EqualValues(t, 6, limited.Load())
nextPasscode, err := totp.GenerateCode(key.Secret(), time.Now().Add(30*time.Second))
require.NoError(t, err)
assert.False(t, ok)
result, err = reloaded.ValidateAndConsumeTOTP(t.Context(), nextPasscode)
require.NoError(t, err)
assert.Equal(t, auth_model.TwoFactorResultTooManyAttempts, result)
cache.Remove(fmt.Sprintf("twofa_attempts_%d", tfa.UID))
result, err = reloaded.ValidateAndConsumeTOTP(t.Context(), nextPasscode)
require.NoError(t, err)
assert.Equal(t, auth_model.TwoFactorResultValid, result)
})
t.Run("DisableTwoFactor", func(t *testing.T) {

View File

@@ -407,6 +407,7 @@
"auth.twofa_scratch_used": "You have used your scratch code. You have been redirected to the two-factor settings page so you may remove your device enrollment or generate a new scratch code.",
"auth.twofa_passcode_incorrect": "Your passcode is incorrect. If you misplaced your device, use your scratch code to sign in.",
"auth.twofa_scratch_token_incorrect": "Your scratch code is incorrect.",
"auth.twofa_too_many_attempts": "Too many incorrect passcodes. Try again later or use your scratch code.",
"auth.twofa_required": "You must set up two-factor authentication to get access to repositories, or try to log in again.",
"auth.login_userpass": "Sign In",
"auth.oauth_signup_tab": "Register New Account",

View File

@@ -10,9 +10,11 @@ import (
audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/services/audit"
"gitea.dev/services/context"
@@ -60,13 +62,13 @@ func TwoFactorPost(ctx *context.Context) {
}
// Validate the passcode and atomically consume it to prevent reuse/replay.
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
tfaResult, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if ok {
if tfaResult == auth.TwoFactorResultValid {
remember := ctx.Session.Get("twofaRemember").(bool) //nolint:forcetypeassert // must exist
u, err := user_model.GetUserByID(ctx, id)
if err != nil {
@@ -86,9 +88,10 @@ func TwoFactorPost(ctx *context.Context) {
if u, err := user_model.GetUserByID(ctx, id); err == nil {
audit.RecordAs(ctx, u, audit_model.UserAuthenticationFailTwoFactor, u)
log.Warn("Failed authentication attempt for %s from %s: invalid two-factor passcode", u.Name, ctx.RemoteAddr())
}
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplTwofa, forms.TwoFactorAuthForm{})
errMsg := ctx.Tr(util.Iif(tfaResult == auth.TwoFactorResultTooManyAttempts, "auth.twofa_too_many_attempts", "auth.twofa_passcode_incorrect"))
ctx.RenderWithErrDeprecated(errMsg, tplTwofa, forms.TwoFactorAuthForm{})
}
// TwoFactorScratch shows the scratch code form for two-factor authentication.

View File

@@ -16,6 +16,7 @@ import (
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/routers/common"
"gitea.dev/services/audit"
@@ -187,15 +188,17 @@ func ResetPasswdPost(ctx *context.Context) {
regenerateScratchToken = true
} else {
passcode := ctx.FormString("passcode")
ok, err := twofa.ValidateAndConsumeTOTP(ctx, passcode)
tfaResult, err := twofa.ValidateAndConsumeTOTP(ctx, passcode)
if err != nil {
ctx.HTTPError(http.StatusInternalServerError, "ValidateAndConsumeTOTP", err.Error())
return
}
if !ok {
if tfaResult != auth.TwoFactorResultValid {
log.Warn("Failed authentication attempt for %s from %s: invalid two-factor passcode", u.Name, ctx.RemoteAddr())
ctx.Data["IsResetForm"] = true
ctx.Data["Err_Passcode"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil)
errMsg := ctx.Tr(util.Iif(tfaResult == auth.TwoFactorResultTooManyAttempts, "auth.twofa_too_many_attempts", "auth.twofa_passcode_incorrect"))
ctx.RenderWithErrDeprecated(errMsg, tplResetPassword, nil)
return
}
}

View File

@@ -176,11 +176,15 @@ func validateTOTP(req *http.Request, u *user_model.User) error {
return err
}
// Consume the passcode atomically so a captured OTP cannot be replayed within its validity window.
if ok, err := twofa.ValidateAndConsumeTOTP(req.Context(), req.Header.Get("X-Gitea-OTP")); err != nil {
tfaResult, err := twofa.ValidateAndConsumeTOTP(req.Context(), req.Header.Get("X-Gitea-OTP"))
if err != nil {
return err
} else if !ok {
} else if tfaResult != auth_model.TwoFactorResultValid {
audit.RecordAs(req.Context(), u, audit_model.UserAuthenticationFailTwoFactor, u)
log.Warn("Failed authentication attempt for %s from %s: invalid provided OTP", u.Name, req.RemoteAddr)
if tfaResult == auth_model.TwoFactorResultTooManyAttempts {
return ErrUserAuthMessage("too many failed OTP attempts, try again later")
}
return util.NewInvalidArgumentErrorf("invalid provided OTP")
}
return nil

View File

@@ -1,6 +1,7 @@
import {createHmac} from 'node:crypto';
import {env} from 'node:process';
import {test, expect} from '@playwright/test';
import {logout} from './utils.ts';
import {test, expect, type Page} from '@playwright/test';
import {apiCreateUser, loginUser, logout, randomString, signInWithPassword} from './utils.ts';
test('homepage', async ({page}) => {
await page.goto('/');
@@ -25,3 +26,42 @@ test('login form in tab order and logout', async ({page}) => {
await expect(page.getByRole('link', {name: 'Sign In'})).toBeHidden();
await logout(page);
});
function totpCode(secret: string, stepOffset: number) {
const bits = Array.from(secret, (char) => 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'.indexOf(char).toString(2).padStart(5, '0')).join('');
const key = Buffer.from(bits.match(/.{8}/g)!.map((byte) => parseInt(byte, 2)));
const counter = Buffer.alloc(8);
counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30000) + stepOffset));
const hmac = createHmac('sha1', key).update(counter).digest();
return String((hmac.readUInt32BE(hmac[19] & 15) & 0x7fffffff) % 1000000).padStart(6, '0');
}
async function submitPasscode(page: Page, passcode: string) {
await page.getByLabel('Passcode').fill(passcode);
await page.getByRole('button', {name: 'Verify'}).click();
}
test('totp sign-in and attempt limit', async ({page, request}) => {
const username = `e2e-totp-${randomString(8)}`;
await apiCreateUser(request, username);
await loginUser(page, username);
await page.goto('/user/settings/security/two_factor/enroll');
const secret = (await page.locator('.user-setting-content img').getAttribute('alt'))!;
await submitPasscode(page, totpCode(secret, 0));
await expect(page.getByText('successfully enrolled')).toBeVisible();
await signInWithPassword(page, username);
await page.waitForURL('**/user/two_factor');
await submitPasscode(page, totpCode(secret, 1));
await expect(page).not.toHaveURL(/\/user\/(login|two_factor)/);
await signInWithPassword(page, username);
await page.waitForURL('**/user/two_factor');
for (let attempt = 0; attempt < 5; attempt++) {
await submitPasscode(page, '0000000');
await expect(page.getByLabel('Passcode')).toHaveValue('');
}
await expect(page.getByText('Your passcode is incorrect')).toBeVisible();
await submitPasscode(page, '0000000');
await expect(page.getByText('Too many incorrect passcodes')).toBeVisible();
});

View File

@@ -238,6 +238,14 @@ export async function loginUser(page: Page, username: string) {
return login(page, username, testUserPassword);
}
export async function signInWithPassword(page: Page, username: string) {
await page.context().clearCookies();
await page.goto('/user/login');
await page.getByLabel('Username or Email Address').fill(username);
await page.getByLabel('Password').fill(testUserPassword);
await page.getByRole('button', {name: 'Sign In', exact: true}).click();
}
export async function login(page: Page, username = env.GITEA_TEST_E2E_USER, password = env.GITEA_TEST_E2E_PASSWORD) {
const response = await page.request.post('/user/login', {
form: {user_name: username, password},

View File

@@ -1,6 +1,6 @@
import {platform} from 'node:process';
import {test, expect, type Page} from '@playwright/test';
import {apiCreateUser, loginUser, randomString, testUserPassword} from './utils.ts';
import {apiCreateUser, loginUser, randomString, signInWithPassword} from './utils.ts';
const signedIn = /^(?!.*\/user\/(login|webauthn))/; // the target of a finished login varies
@@ -10,14 +10,6 @@ async function registerKey(page: Page, nickname: string) {
await page.getByRole('button', {name: 'Add Security Key'}).click();
}
async function signInWithPassword(page: Page, username: string) {
await page.context().clearCookies();
await page.goto('/user/login');
await page.getByLabel('Username or Email Address').fill(username);
await page.getByLabel('Password').fill(testUserPassword);
await page.getByRole('button', {name: 'Sign In', exact: true}).click();
}
// regression: credProtect level 3 hid the credential from the second-factor login
test('security key survives credProtect', async ({page, request, browserName}) => {
test.skip(browserName !== 'chromium', 'only the CDP authenticator emulates credProtect'); // eslint-disable-line playwright/no-skipped-test -- conditional skip, the reason is in the message

View File

@@ -107,5 +107,13 @@ func TestAPITwoFactor(t *testing.T) {
AddBasicAuth(user.Name)
req.Header.Set("X-Gitea-OTP", passcode)
MakeRequest(t, req, http.StatusUnauthorized)
for range 4 {
MakeRequest(t, NewRequest(t, "GET", "/api/v1/user").AddBasicAuth(user.Name).SetHeader("X-Gitea-OTP", "0000000"), http.StatusUnauthorized)
}
nextPasscode, err := totp.GenerateCode(otpKey.Secret(), time.Now().Add(30*time.Second))
assert.NoError(t, err)
resp := MakeRequest(t, NewRequest(t, "GET", "/api/v1/user").AddBasicAuth(user.Name).SetHeader("X-Gitea-OTP", nextPasscode), http.StatusUnauthorized)
assert.Contains(t, resp.Body.String(), "too many failed OTP attempts")
})
}