From 67a6bd7fc0c60e39e602d3ff9623326e6035d5f3 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Wed, 1 Jul 2026 04:33:16 -0600 Subject: [PATCH] feat(auth): add `disable-2fa` command (#38275) This PR adds the `gitea admin user disable-2fa` command to disable 2FA for a user When the only admin in the instance loses their 2FA credentials, this command can be used to disable 2FA, allowing them to log in and reset it. --------- Co-authored-by: Giteabot --- cmd/admin_user.go | 1 + cmd/admin_user_disable_2fa.go | 72 +++++++++++++++++ cmd/admin_user_disable_2fa_test.go | 119 +++++++++++++++++++++++++++++ models/auth/twofactor.go | 15 ++++ models/auth/twofactor_test.go | 35 +++++++++ routers/web/admin/users.go | 22 +----- 6 files changed, 244 insertions(+), 20 deletions(-) create mode 100644 cmd/admin_user_disable_2fa.go create mode 100644 cmd/admin_user_disable_2fa_test.go diff --git a/cmd/admin_user.go b/cmd/admin_user.go index 8dd8bb4eca1..b45aee1895d 100644 --- a/cmd/admin_user.go +++ b/cmd/admin_user.go @@ -18,6 +18,7 @@ func newUserCommand() *cli.Command { microcmdUserDelete(), newUserGenerateAccessTokenCommand(), microcmdUserMustChangePassword(), + microcmdUserDisableTwoFactor(), }, } } diff --git a/cmd/admin_user_disable_2fa.go b/cmd/admin_user_disable_2fa.go new file mode 100644 index 00000000000..1e8502cf04c --- /dev/null +++ b/cmd/admin_user_disable_2fa.go @@ -0,0 +1,72 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + + auth_model "gitea.dev/models/auth" + user_model "gitea.dev/models/user" + "gitea.dev/modules/setting" + + "github.com/urfave/cli/v3" +) + +func microcmdUserDisableTwoFactor() *cli.Command { + return &cli.Command{ + Name: "disable-2fa", + Usage: "Disable two-factor authentication for a user", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Usage: "Username of the user to disable 2FA for", + }, + &cli.Int64Flag{ + Name: "id", + Usage: "ID of the user to disable 2FA for", + }, + }, + Action: runDisableTwoFactor, + } +} + +func runDisableTwoFactor(ctx context.Context, c *cli.Command) error { + if !c.IsSet("id") && !c.IsSet("username") { + return errors.New("either --id or --username must be provided") + } + + if !setting.IsInTesting { + if err := initDB(ctx); err != nil { + return err + } + } + + var user *user_model.User + var err error + if c.IsSet("id") { + user, err = user_model.GetUserByID(ctx, c.Int64("id")) + } else { + user, err = user_model.GetUserByName(ctx, c.String("username")) + } + if err != nil { + return err + } + + // When both selectors are given, make sure they refer to the same user. + if c.IsSet("id") && c.IsSet("username") && user.LowerName != strings.ToLower(strings.TrimSpace(c.String("username"))) { + return fmt.Errorf("the user with id %d is %q, which does not match the provided username %q", user.ID, user.Name, c.String("username")) + } + + totp, webAuthn, err := auth_model.DisableTwoFactor(ctx, user.ID) + if err != nil { + return err + } + + fmt.Printf("Disabled 2FA for user %q (removed %d TOTP and %d WebAuthn credential(s))\n", user.Name, totp, webAuthn) + return nil +} diff --git a/cmd/admin_user_disable_2fa_test.go b/cmd/admin_user_disable_2fa_test.go new file mode 100644 index 00000000000..4fe94974bbf --- /dev/null +++ b/cmd/admin_user_disable_2fa_test.go @@ -0,0 +1,119 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "io" + "strconv" + "testing" + + auth_model "gitea.dev/models/auth" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + + "github.com/go-webauthn/webauthn/webauthn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDisableTwoFactorCommand(t *testing.T) { + ctx := t.Context() + + defer func() { + require.NoError(t, db.TruncateBeans(t.Context(), &user_model.User{}, &auth_model.TwoFactor{}, &auth_model.WebAuthnCredential{})) + }() + + t.Run("disable TOTP and WebAuthn", func(t *testing.T) { + require.NoError(t, microcmdUserCreate().Run(ctx, []string{"create", "--username", "tfuser", "--email", "tfuser@gitea.local", "--random-password"})) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "tfuser"}) + + // Enroll TOTP. + tf := &auth_model.TwoFactor{UID: user.ID} + require.NoError(t, tf.SetSecret("test-secret")) + _, err := tf.GenerateScratchToken() + require.NoError(t, err) + require.NoError(t, auth_model.NewTwoFactor(ctx, tf)) + + // Register a WebAuthn credential. + _, err = auth_model.CreateCredential(ctx, user.ID, "test-key", &webauthn.Credential{ID: []byte("test-cred-id")}) + require.NoError(t, err) + + has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, user.ID) + require.NoError(t, err) + require.True(t, has) + + require.NoError(t, microcmdUserDisableTwoFactor().Run(ctx, []string{"disable-2fa", "--username", "tfuser"})) + + // Both factors must be gone afterwards. + has, err = auth_model.HasTwoFactorOrWebAuthn(ctx, user.ID) + require.NoError(t, err) + assert.False(t, has) + }) + + t.Run("disable by id", func(t *testing.T) { + require.NoError(t, microcmdUserCreate().Run(ctx, []string{"create", "--username", "iduser", "--email", "iduser@gitea.local", "--random-password"})) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "iduser"}) + + tf := &auth_model.TwoFactor{UID: user.ID} + require.NoError(t, tf.SetSecret("test-secret")) + require.NoError(t, auth_model.NewTwoFactor(ctx, tf)) + + require.NoError(t, microcmdUserDisableTwoFactor().Run(ctx, []string{"disable-2fa", "--id", strconv.FormatInt(user.ID, 10)})) + + has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, user.ID) + require.NoError(t, err) + assert.False(t, has) + }) + + t.Run("no enrollment is a no-op", func(t *testing.T) { + require.NoError(t, microcmdUserCreate().Run(ctx, []string{"create", "--username", "plainuser", "--email", "plainuser@gitea.local", "--random-password"})) + require.NoError(t, microcmdUserDisableTwoFactor().Run(ctx, []string{"disable-2fa", "--username", "plainuser"})) + }) + + t.Run("id and username must match when both given", func(t *testing.T) { + require.NoError(t, microcmdUserCreate().Run(ctx, []string{"create", "--username", "matchuser", "--email", "matchuser@gitea.local", "--random-password"})) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "matchuser"}) + id := strconv.FormatInt(user.ID, 10) + + // Matching id + username is accepted. + require.NoError(t, microcmdUserDisableTwoFactor().Run(ctx, []string{"disable-2fa", "--id", id, "--username", "matchuser"})) + + // Mismatched id + username is rejected. + cmd := microcmdUserDisableTwoFactor() + cmd.Writer, cmd.ErrWriter = io.Discard, io.Discard + err := cmd.Run(ctx, []string{"disable-2fa", "--id", id, "--username", "someotheruser"}) + require.Error(t, err) + require.Contains(t, err.Error(), "does not match the provided username") + }) + + t.Run("failure cases", func(t *testing.T) { + testCases := []struct { + name string + args []string + expectedErr string + }{ + { + name: "user does not exist", + args: []string{"disable-2fa", "--username", "nonexistentuser"}, + expectedErr: "user does not exist", + }, + { + name: "neither id nor username", + args: []string{"disable-2fa"}, + expectedErr: "either --id or --username must be provided", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cmd := microcmdUserDisableTwoFactor() + cmd.Writer, cmd.ErrWriter = io.Discard, io.Discard + err := cmd.Run(ctx, tc.args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErr) + }) + } + }) +} diff --git a/models/auth/twofactor.go b/models/auth/twofactor.go index 51f487aac12..0362879e7e0 100644 --- a/models/auth/twofactor.go +++ b/models/auth/twofactor.go @@ -195,3 +195,18 @@ func HasTwoFactorOrWebAuthn(ctx context.Context, id int64) (bool, error) { } return HasWebAuthnRegistrationsByUID(ctx, id) } + +// DisableTwoFactor removes every two-factor method of the given user atomically, +// returning the number of TOTP records and WebAuthn credentials removed. +// It is a no-op for a user that has no 2FA enrolled. +func DisableTwoFactor(ctx context.Context, uid int64) (totp, webAuthn int64, err error) { + err = db.WithTx(ctx, func(ctx context.Context) error { + var e error + if totp, e = db.GetEngine(ctx).Where("uid = ?", uid).Delete(&TwoFactor{}); e != nil { + return e + } + webAuthn, e = db.GetEngine(ctx).Where("user_id = ?", uid).Delete(&WebAuthnCredential{}) + return e + }) + return totp, webAuthn, err +} diff --git a/models/auth/twofactor_test.go b/models/auth/twofactor_test.go index 1da5814e03f..835e7c9c418 100644 --- a/models/auth/twofactor_test.go +++ b/models/auth/twofactor_test.go @@ -10,6 +10,7 @@ import ( auth_model "gitea.dev/models/auth" "gitea.dev/models/unittest" + "github.com/go-webauthn/webauthn/webauthn" "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -45,3 +46,37 @@ func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) { require.NoError(t, err) assert.False(t, ok) } + +func TestDisableTwoFactor(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + const uid = 1000 // a uid with no user/2FA fixtures + + // Enroll TOTP and register a WebAuthn credential. + tfa := &auth_model.TwoFactor{UID: uid} + require.NoError(t, tfa.SetSecret("test-secret")) + require.NoError(t, auth_model.NewTwoFactor(ctx, tfa)) + _, err := auth_model.CreateCredential(ctx, uid, "test-key", &webauthn.Credential{ID: []byte("test-cred-id")}) + require.NoError(t, err) + + has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, uid) + require.NoError(t, err) + require.True(t, has) + + // Both records are removed and counted separately. + totp, webAuthn, err := auth_model.DisableTwoFactor(ctx, uid) + require.NoError(t, err) + assert.EqualValues(t, 1, totp) + assert.EqualValues(t, 1, webAuthn) + + has, err = auth_model.HasTwoFactorOrWebAuthn(ctx, uid) + require.NoError(t, err) + assert.False(t, has) + + // A second call on a user without 2FA is a no-op. + totp, webAuthn, err = auth_model.DisableTwoFactor(ctx, uid) + require.NoError(t, err) + assert.EqualValues(t, 0, totp) + assert.EqualValues(t, 0, webAuthn) +} diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index f918c8b5d31..e5844350aa5 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -450,27 +450,9 @@ func EditUserPost(ctx *context.Context) { log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, u.Name) if form.Reset2FA { - tf, err := auth.GetTwoFactorByUID(ctx, u.ID) - if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) { - ctx.ServerError("auth.GetTwoFactorByUID", err) + if _, _, err := auth.DisableTwoFactor(ctx, u.ID); err != nil { + ctx.ServerError("auth.DisableTwoFactor", err) return - } else if tf != nil { - if err := auth.DeleteTwoFactorByID(ctx, tf.ID, u.ID); err != nil { - ctx.ServerError("auth.DeleteTwoFactorByID", err) - return - } - } - - wn, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) - if err != nil { - ctx.ServerError("auth.GetTwoFactorByUID", err) - return - } - for _, cred := range wn { - if _, err := auth.DeleteCredential(ctx, cred.ID, u.ID); err != nil { - ctx.ServerError("auth.DeleteCredential", err) - return - } } }