Skip to content
1 change: 1 addition & 0 deletions cmd/admin_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func newUserCommand() *cli.Command {
microcmdUserDelete(),
newUserGenerateAccessTokenCommand(),
microcmdUserMustChangePassword(),
microcmdUserDisableTwoFactor(),
},
}
}
69 changes: 69 additions & 0 deletions cmd/admin_user_disable_2fa.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package cmd

import (
"context"
"errors"
"fmt"

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 c.IsSet("id") && c.IsSet("username") {
Comment thread
Zettat123 marked this conversation as resolved.
Outdated
return errors.New("provide exactly one of --id or --username")
}

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
}

removed, err := auth_model.DisableTwoFactor(ctx, user.ID)
if err != nil {
return err
}

fmt.Printf("Disabled 2FA for user %q (removed %d credential(s))\n", user.Name, removed)
return nil
}
108 changes: 108 additions & 0 deletions cmd/admin_user_disable_2fa_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// 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("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",
},
{
name: "both id and username",
args: []string{"disable-2fa", "--id", "1", "--username", "someuser"},
expectedErr: "provide exactly one of --id or --username",
},
}

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)
})
}
})
}
16 changes: 16 additions & 0 deletions models/auth/twofactor.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,19 @@ func HasTwoFactorOrWebAuthn(ctx context.Context, id int64) (bool, error) {
}
return HasWebAuthnRegistrationsByUID(ctx, id)
}

// DisableTwoFactor removes every two-factor method of the given user atomically.
// It is a no-op for a user that has no 2FA enrolled.
func DisableTwoFactor(ctx context.Context, uid int64) (int64, error) {
Comment thread
Zettat123 marked this conversation as resolved.
Outdated
return db.WithTx2(ctx, func(ctx context.Context) (int64, error) {
totp, err := db.GetEngine(ctx).Where("uid = ?", uid).Delete(&TwoFactor{})
if err != nil {
return 0, err
}
webAuthn, err := db.GetEngine(ctx).Where("user_id = ?", uid).Delete(&WebAuthnCredential{})
if err != nil {
return 0, err
}
return totp + webAuthn, nil
})
}
33 changes: 33 additions & 0 deletions models/auth/twofactor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -45,3 +46,35 @@ 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.
removed, err := auth_model.DisableTwoFactor(ctx, uid)
require.NoError(t, err)
assert.EqualValues(t, 2, removed)

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.
removed, err = auth_model.DisableTwoFactor(ctx, uid)
require.NoError(t, err)
assert.EqualValues(t, 0, removed)
}
22 changes: 2 additions & 20 deletions routers/web/admin/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}

Expand Down
Loading