From 7df94ff7b2dfe53042ecfda1cb4c805e0e20aad9 Mon Sep 17 00:00:00 2001 From: Ian Spence Date: Thu, 5 Jun 2025 10:40:21 +0200 Subject: [PATCH] feat: add `admin user reset-mfa` CLI command (#8047) ### Pull Request Description: This Pull Request adds a new `admin user reset-mfa` option to the CLI which lets admins remove all two-factor authentication configurations for a user (both totp & passkey). Like with `reset-password` this lets admins help unblock users who might be locked out. ### Justification: Although users are told to back up or store TOTP recovery keys in a safe place, this is hardly a fool-proof design (not the fault of Forgejo or Gitea), in addition, passkeys don't really provide any means of recovery should the key no longer be available. It's entirely possible to become totally locked out of your foregejo account because you cannot complete a two-factor challenge. Providing a means to recover from this lockout scenario through the existing CLI tool parallels the scenario of forgetting a password. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [ ] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [ ] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] I do not want this change to show in the release notes. - [x] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. ## Release notes - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/8047): Adds `admin user reset-mfa` CLI option Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8047 Reviewed-by: Earl Warren Co-authored-by: Ian Spence Co-committed-by: Ian Spence --- cmd/admin_user.go | 1 + cmd/admin_user_reset_mfa.go | 73 +++++++++++++++++++++++++++++ tests/integration/cmd_admin_test.go | 40 ++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 cmd/admin_user_reset_mfa.go 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) + }) +}