mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-07 07:16:09 +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(),
|
microcmdUserDelete(),
|
||||||
newUserGenerateAccessTokenCommand(),
|
newUserGenerateAccessTokenCommand(),
|
||||||
microcmdUserMustChangePassword(),
|
microcmdUserMustChangePassword(),
|
||||||
|
microcmdUserDisableTwoFactor(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
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"
|
auth_model "gitea.dev/models/auth"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -45,3 +46,37 @@ func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, ok)
|
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)
|
log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, u.Name)
|
||||||
|
|
||||||
if form.Reset2FA {
|
if form.Reset2FA {
|
||||||
tf, err := auth.GetTwoFactorByUID(ctx, u.ID)
|
if _, _, err := auth.DisableTwoFactor(ctx, u.ID); err != nil {
|
||||||
if err != nil && !auth.IsErrTwoFactorNotEnrolled(err) {
|
ctx.ServerError("auth.DisableTwoFactor", err)
|
||||||
ctx.ServerError("auth.GetTwoFactorByUID", err)
|
|
||||||
return
|
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