diff --git a/cmd/admin_user.go b/cmd/admin_user.go index 1ba3ef7e95..f4f6fb49af 100644 --- a/cmd/admin_user.go +++ b/cmd/admin_user.go @@ -18,6 +18,7 @@ func subcmdUser() *cli.Command { microcmdUserDelete(), microcmdUserGenerateAccessToken(), microcmdUserMustChangePassword(), + microcmdUserResetMFA(), }, } } diff --git a/cmd/admin_user_reset_mfa.go b/cmd/admin_user_reset_mfa.go new file mode 100644 index 0000000000..8107fd97bf --- /dev/null +++ b/cmd/admin_user_reset_mfa.go @@ -0,0 +1,73 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + + auth_model "forgejo.org/models/auth" + user_model "forgejo.org/models/user" + + "github.com/urfave/cli/v3" +) + +func microcmdUserResetMFA() *cli.Command { + return &cli.Command{ + Name: "reset-mfa", + Usage: "Remove all two-factor authentication configurations for a user", + Action: runResetMFA, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Value: "", + Usage: "The user to update", + }, + }, + } +} + +func runResetMFA(ctx context.Context, c *cli.Command) error { + if err := argsSet(c, "username"); err != nil { + return err + } + + ctx, cancel := installSignals(ctx) + defer cancel() + + if err := initDB(ctx); err != nil { + return err + } + + user, err := user_model.GetUserByName(ctx, c.String("username")) + if err != nil { + return err + } + + webAuthnList, err := auth_model.GetWebAuthnCredentialsByUID(ctx, user.ID) + if err != nil { + return err + } + + for _, credential := range webAuthnList { + if _, err := auth_model.DeleteCredential(ctx, credential.ID, user.ID); err != nil { + return err + } + } + + tfaModes, err := auth_model.GetTwoFactorByUID(ctx, user.ID) + if err == nil && tfaModes != nil { + if err := auth_model.DeleteTwoFactorByID(ctx, tfaModes.ID, user.ID); err != nil { + return err + } + } else { + if _, is := err.(auth_model.ErrTwoFactorNotEnrolled); !is { + return err + } + } + + fmt.Printf("%s's two-factor authentication settings have been removed!\n", user.Name) + return nil +} diff --git a/tests/integration/cmd_admin_test.go b/tests/integration/cmd_admin_test.go index c1f7de39e9..c06f7f7213 100644 --- a/tests/integration/cmd_admin_test.go +++ b/tests/integration/cmd_admin_test.go @@ -8,11 +8,13 @@ import ( "net/url" "testing" + auth_model "forgejo.org/models/auth" "forgejo.org/models/db" "forgejo.org/models/unittest" user_model "forgejo.org/models/user" "forgejo.org/tests" + "github.com/go-webauthn/webauthn/webauthn" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -148,3 +150,41 @@ func Test_Cmd_AdminFirstUser(t *testing.T) { } }) } + +func Test_Cmd_AdminUserResetMFA(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + name := "testuser" + + options := []string{"user", "create", "--username", name, "--password", "password", "--email", name + "@example.com"} + output, err := runMainApp("admin", options...) + require.NoError(t, err) + assert.Contains(t, output, "has been successfully created") + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: name}) + + twoFactor := &auth_model.TwoFactor{ + UID: user.ID, + } + token := twoFactor.GenerateScratchToken() + require.NoError(t, auth_model.NewTwoFactor(t.Context(), twoFactor, token)) + twoFactor, err = auth_model.GetTwoFactorByUID(t.Context(), user.ID) + require.NoError(t, err) + require.NotNil(t, twoFactor) + + authn, err := auth_model.CreateCredential(t.Context(), user.ID, "test", &webauthn.Credential{}) + require.NoError(t, err) + + options = []string{"user", "reset-mfa", "--username", name} + output, err = runMainApp("admin", options...) + require.NoError(t, err) + assert.Contains(t, output, "two-factor authentication settings have been removed") + + _, err = auth_model.GetTwoFactorByUID(t.Context(), user.ID) + require.ErrorContains(t, err, "user not enrolled in 2FA") + + _, err = auth_model.GetWebAuthnCredentialByID(t.Context(), authn.ID) + require.ErrorContains(t, err, "WebAuthn credential does not exist") + + _, err = runMainApp("admin", "user", "delete", "--username", name) + require.NoError(t, err) + }) +}