mirror of
https://github.com/go-gitea/gitea.git
synced 2026-07-02 02:50:22 +00:00
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 <teabot@gitea.io>
This commit is contained in:
@@ -18,6 +18,7 @@ func newUserCommand() *cli.Command {
|
||||
microcmdUserDelete(),
|
||||
newUserGenerateAccessTokenCommand(),
|
||||
microcmdUserMustChangePassword(),
|
||||
microcmdUserDisableTwoFactor(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
72
cmd/admin_user_disable_2fa.go
Normal file
72
cmd/admin_user_disable_2fa.go
Normal file
@@ -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
|
||||
}
|
||||
119
cmd/admin_user_disable_2fa_test.go
Normal file
119
cmd/admin_user_disable_2fa_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user