summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLibravatar kim <grufwub@gmail.com>2025-08-13 12:24:40 +0200
committerLibravatar kim <gruf@noreply.codeberg.org>2025-08-13 12:24:40 +0200
commit7f8cb204cd5a58eb143ab20a21bfa32bd8c3c26b (patch)
treee8a10032b73bd1c60df5c8e8fdf7817410d67e1a
parent[chore] bump to code.superseriousbusiness.org/oauth2/v4@ssb-v4.5.3-2 (#4367) (diff)
downloadgotosocial-7f8cb204cd5a58eb143ab20a21bfa32bd8c3c26b.tar.xz
[feature] 2fa management via CLI (#4368)
Adds 2FA management to the admin CLI. Also does some CLI refactoring so the functions we pass around are exported functions instead of changeable global variables. closes https://codeberg.org/superseriousbusiness/gotosocial/issues/4320 Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4368 Co-authored-by: kim <grufwub@gmail.com> Co-committed-by: kim <grufwub@gmail.com>
-rw-r--r--cmd/gotosocial/action/admin/account/account.go67
-rw-r--r--cmd/gotosocial/action/admin/media/list.go8
-rw-r--r--cmd/gotosocial/action/admin/media/prune/all.go5
-rw-r--r--cmd/gotosocial/action/admin/media/prune/orphaned.go5
-rw-r--r--cmd/gotosocial/action/admin/media/prune/remote.go5
-rw-r--r--cmd/gotosocial/action/admin/trans/export.go5
-rw-r--r--cmd/gotosocial/action/admin/trans/import.go5
-rw-r--r--cmd/gotosocial/action/debug/config/config.go5
-rw-r--r--cmd/gotosocial/action/migration/run.go5
-rw-r--r--cmd/gotosocial/action/server/server.go8
-rw-r--r--cmd/gotosocial/action/testrig/no_testrig.go11
-rw-r--r--cmd/gotosocial/action/testrig/testrig.go5
-rw-r--r--cmd/gotosocial/admin.go13
-rw-r--r--cmd/gotosocial/testrig.go3
-rw-r--r--internal/processing/user/twofactor.go187
15 files changed, 224 insertions, 113 deletions
diff --git a/cmd/gotosocial/action/admin/account/account.go b/cmd/gotosocial/action/admin/account/account.go
index 2c12f90bb..16b8bb807 100644
--- a/cmd/gotosocial/action/admin/account/account.go
+++ b/cmd/gotosocial/action/admin/account/account.go
@@ -29,12 +29,25 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/db/bundb"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
"code.superseriousbusiness.org/gotosocial/internal/log"
+ userprocessor "code.superseriousbusiness.org/gotosocial/internal/processing/user"
"code.superseriousbusiness.org/gotosocial/internal/state"
"code.superseriousbusiness.org/gotosocial/internal/util"
"code.superseriousbusiness.org/gotosocial/internal/validate"
"golang.org/x/crypto/bcrypt"
)
+var (
+ // check function conformance
+ _ action.GTSAction = Create
+ _ action.GTSAction = List
+ _ action.GTSAction = Confirm
+ _ action.GTSAction = Promote
+ _ action.GTSAction = Demote
+ _ action.GTSAction = Enable
+ _ action.GTSAction = Disable
+ _ action.GTSAction = Password
+)
+
func initState(ctx context.Context) (*state.State, error) {
var state state.State
state.Caches.Init()
@@ -61,7 +74,7 @@ func stopState(state *state.State) error {
// Create creates a new account and user
// in the database using the provided flags.
-var Create action.GTSAction = func(ctx context.Context) error {
+func Create(ctx context.Context) error {
state, err := initState(ctx)
if err != nil {
return err
@@ -118,7 +131,7 @@ var Create action.GTSAction = func(ctx context.Context) error {
}
// List returns all existing local accounts.
-var List action.GTSAction = func(ctx context.Context) error {
+func List(ctx context.Context) error {
state, err := initState(ctx)
if err != nil {
return err
@@ -156,7 +169,7 @@ var List action.GTSAction = func(ctx context.Context) error {
// Confirm sets a user to Approved, sets Email to the current
// UnconfirmedEmail value, and sets ConfirmedAt to now.
-var Confirm action.GTSAction = func(ctx context.Context) error {
+func Confirm(ctx context.Context) error {
state, err := initState(ctx)
if err != nil {
return err
@@ -198,7 +211,7 @@ var Confirm action.GTSAction = func(ctx context.Context) error {
}
// Promote sets admin + moderator flags on a user to true.
-var Promote action.GTSAction = func(ctx context.Context) error {
+func Promote(ctx context.Context) error {
state, err := initState(ctx)
if err != nil {
return err
@@ -235,7 +248,7 @@ var Promote action.GTSAction = func(ctx context.Context) error {
}
// Demote sets admin + moderator flags on a user to false.
-var Demote action.GTSAction = func(ctx context.Context) error {
+func Demote(ctx context.Context) error {
state, err := initState(ctx)
if err != nil {
return err
@@ -272,7 +285,7 @@ var Demote action.GTSAction = func(ctx context.Context) error {
}
// Disable sets Disabled to true on a user.
-var Disable action.GTSAction = func(ctx context.Context) error {
+func Disable(ctx context.Context) error {
state, err := initState(ctx)
if err != nil {
return err
@@ -308,7 +321,7 @@ var Disable action.GTSAction = func(ctx context.Context) error {
}
// Enable sets Disabled to false on a user.
-var Enable action.GTSAction = func(ctx context.Context) error {
+func Enable(ctx context.Context) error {
state, err := initState(ctx)
if err != nil {
return err
@@ -344,7 +357,7 @@ var Enable action.GTSAction = func(ctx context.Context) error {
}
// Password sets the password of target account.
-var Password action.GTSAction = func(ctx context.Context) error {
+func Password(ctx context.Context) error {
state, err := initState(ctx)
if err != nil {
return err
@@ -389,3 +402,41 @@ var Password action.GTSAction = func(ctx context.Context) error {
"encrypted_password",
)
}
+
+// Disable2FA disables 2FA for target account.
+func Disable2FA(ctx context.Context) error {
+ state, err := initState(ctx)
+ if err != nil {
+ return err
+ }
+
+ defer func() {
+ // Ensure state gets stopped on return.
+ if err := stopState(state); err != nil {
+ log.Error(ctx, err)
+ }
+ }()
+
+ username := config.GetAdminAccountUsername()
+ if err := validate.Username(username); err != nil {
+ return err
+ }
+
+ account, err := state.DB.GetAccountByUsernameDomain(ctx, username, "")
+ if err != nil {
+ return err
+ }
+
+ user, err := state.DB.GetUserByAccountID(ctx, account.ID)
+ if err != nil {
+ return err
+ }
+
+ err = userprocessor.TwoFactorDisable(ctx, state, user)
+ if err != nil {
+ return err
+ }
+
+ fmt.Printf("2fa disabled\n")
+ return nil
+}
diff --git a/cmd/gotosocial/action/admin/media/list.go b/cmd/gotosocial/action/admin/media/list.go
index a07bf4145..8b8df204b 100644
--- a/cmd/gotosocial/action/admin/media/list.go
+++ b/cmd/gotosocial/action/admin/media/list.go
@@ -35,6 +35,10 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/state"
)
+// check function conformance.
+var _ action.GTSAction = ListAttachments
+var _ action.GTSAction = ListEmojis
+
type list struct {
dbService db.DB
state *state.State
@@ -155,7 +159,7 @@ func (l *list) shutdown() error {
}
// ListAttachments lists local, remote, or all attachment paths.
-var ListAttachments action.GTSAction = func(ctx context.Context) error {
+func ListAttachments(ctx context.Context) error {
list, err := setupList(ctx)
if err != nil {
return err
@@ -214,7 +218,7 @@ var ListAttachments action.GTSAction = func(ctx context.Context) error {
}
// ListEmojis lists local, remote, or all emoji filepaths.
-var ListEmojis action.GTSAction = func(ctx context.Context) error {
+func ListEmojis(ctx context.Context) error {
list, err := setupList(ctx)
if err != nil {
return err
diff --git a/cmd/gotosocial/action/admin/media/prune/all.go b/cmd/gotosocial/action/admin/media/prune/all.go
index 9c2a6a99f..f030517e7 100644
--- a/cmd/gotosocial/action/admin/media/prune/all.go
+++ b/cmd/gotosocial/action/admin/media/prune/all.go
@@ -26,8 +26,11 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/log"
)
+// check function conformance.
+var _ action.GTSAction = All
+
// All performs all media clean actions
-var All action.GTSAction = func(ctx context.Context) error {
+func All(ctx context.Context) error {
// Setup pruning utilities.
prune, err := setupPrune(ctx)
if err != nil {
diff --git a/cmd/gotosocial/action/admin/media/prune/orphaned.go b/cmd/gotosocial/action/admin/media/prune/orphaned.go
index 4894e8900..a58485455 100644
--- a/cmd/gotosocial/action/admin/media/prune/orphaned.go
+++ b/cmd/gotosocial/action/admin/media/prune/orphaned.go
@@ -26,8 +26,11 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/log"
)
+// check function conformance.
+var _ action.GTSAction = Orphaned
+
// Orphaned prunes orphaned media from storage.
-var Orphaned action.GTSAction = func(ctx context.Context) error {
+func Orphaned(ctx context.Context) error {
// Setup pruning utilities.
prune, err := setupPrune(ctx)
if err != nil {
diff --git a/cmd/gotosocial/action/admin/media/prune/remote.go b/cmd/gotosocial/action/admin/media/prune/remote.go
index fdb3a3ce9..270c9baaf 100644
--- a/cmd/gotosocial/action/admin/media/prune/remote.go
+++ b/cmd/gotosocial/action/admin/media/prune/remote.go
@@ -27,8 +27,11 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/log"
)
+// check function conformance.
+var _ action.GTSAction = Remote
+
// Remote prunes old and/or unused remote media.
-var Remote action.GTSAction = func(ctx context.Context) error {
+func Remote(ctx context.Context) error {
// Setup pruning utilities.
prune, err := setupPrune(ctx)
if err != nil {
diff --git a/cmd/gotosocial/action/admin/trans/export.go b/cmd/gotosocial/action/admin/trans/export.go
index 8984a2bab..221a33c70 100644
--- a/cmd/gotosocial/action/admin/trans/export.go
+++ b/cmd/gotosocial/action/admin/trans/export.go
@@ -29,8 +29,11 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/trans"
)
+// check function conformance.
+var _ action.GTSAction = Export
+
// Export exports info from the database into a file
-var Export action.GTSAction = func(ctx context.Context) error {
+func Export(ctx context.Context) error {
var state state.State
// Only set state DB connection.
diff --git a/cmd/gotosocial/action/admin/trans/import.go b/cmd/gotosocial/action/admin/trans/import.go
index 00762bee3..e3e20105c 100644
--- a/cmd/gotosocial/action/admin/trans/import.go
+++ b/cmd/gotosocial/action/admin/trans/import.go
@@ -29,8 +29,11 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/trans"
)
+// check function conformance.
+var _ action.GTSAction = Import
+
// Import imports info from a file into the database
-var Import action.GTSAction = func(ctx context.Context) error {
+func Import(ctx context.Context) error {
var state state.State
// Only set state DB connection.
diff --git a/cmd/gotosocial/action/debug/config/config.go b/cmd/gotosocial/action/debug/config/config.go
index f79f5234d..ead019e69 100644
--- a/cmd/gotosocial/action/debug/config/config.go
+++ b/cmd/gotosocial/action/debug/config/config.go
@@ -26,8 +26,11 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/config"
)
+// check function conformance.
+var _ action.GTSAction = Config
+
// Config just prints the collated config out to stdout as json.
-var Config action.GTSAction = func(ctx context.Context) (err error) {
+func Config(ctx context.Context) (err error) {
var raw map[string]interface{}
// Marshal configuration to a raw JSON map
diff --git a/cmd/gotosocial/action/migration/run.go b/cmd/gotosocial/action/migration/run.go
index 61cec035b..d63160073 100644
--- a/cmd/gotosocial/action/migration/run.go
+++ b/cmd/gotosocial/action/migration/run.go
@@ -27,8 +27,11 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/state"
)
+// check function conformance.
+var _ action.GTSAction = Run
+
// Run will initialize the database, running any available migrations.
-var Run action.GTSAction = func(ctx context.Context) error {
+func Run(ctx context.Context) error {
var state state.State
defer func() {
diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go
index afd908304..85f19b9db 100644
--- a/cmd/gotosocial/action/server/server.go
+++ b/cmd/gotosocial/action/server/server.go
@@ -70,9 +70,13 @@ import (
"go.uber.org/automaxprocs/maxprocs"
)
+// check function conformance.
+var _ action.GTSAction = Maintenance
+var _ action.GTSAction = Start
+
// Maintenance starts and creates a GoToSocial server
// in maintenance mode (returns 503 for most requests).
-var Maintenance action.GTSAction = func(ctx context.Context) error {
+func Maintenance(ctx context.Context) error {
route, err := router.New(ctx)
if err != nil {
return fmt.Errorf("error creating maintenance router: %w", err)
@@ -101,7 +105,7 @@ var Maintenance action.GTSAction = func(ctx context.Context) error {
}
// Start creates and starts a gotosocial server
-var Start action.GTSAction = func(ctx context.Context) error {
+func Start(ctx context.Context) error {
// Set GOMAXPROCS / GOMEMLIMIT
// to match container limits.
setLimits(ctx)
diff --git a/cmd/gotosocial/action/testrig/no_testrig.go b/cmd/gotosocial/action/testrig/no_testrig.go
index 07e9960dc..d5459b200 100644
--- a/cmd/gotosocial/action/testrig/no_testrig.go
+++ b/cmd/gotosocial/action/testrig/no_testrig.go
@@ -19,8 +19,15 @@
package testrig
-import "code.superseriousbusiness.org/gotosocial/cmd/gotosocial/action"
+import (
+ "context"
+
+ "code.superseriousbusiness.org/gotosocial/cmd/gotosocial/action"
+)
+
+// check function conformance.
+var _ action.GTSAction = Start
// Start creates and starts a gotosocial testrig server.
// This is only enabled in debug builds, else is nil.
-var Start action.GTSAction
+func Start(context.Context) error { return nil }
diff --git a/cmd/gotosocial/action/testrig/testrig.go b/cmd/gotosocial/action/testrig/testrig.go
index 2b70a3447..6399be41c 100644
--- a/cmd/gotosocial/action/testrig/testrig.go
+++ b/cmd/gotosocial/action/testrig/testrig.go
@@ -50,9 +50,12 @@ import (
"github.com/gin-gonic/gin"
)
+// check function conformance.
+var _ action.GTSAction = Start
+
// Start creates and starts a gotosocial testrig server.
// This is only enabled in debug builds, else is nil.
-var Start action.GTSAction = func(ctx context.Context) error {
+func Start(ctx context.Context) error {
testrig.InitTestConfig()
testrig.InitTestLog()
diff --git a/cmd/gotosocial/admin.go b/cmd/gotosocial/admin.go
index 63c37a7ce..93fc9c1f8 100644
--- a/cmd/gotosocial/admin.go
+++ b/cmd/gotosocial/admin.go
@@ -146,6 +146,19 @@ func adminCommands() *cobra.Command {
config.AddAdminAccountPassword(adminAccountPasswordCmd)
adminAccountCmd.AddCommand(adminAccountPasswordCmd)
+ adminAccountDisable2FACmd := &cobra.Command{
+ Use: "disable-2fa",
+ Short: "disable 2fa for the given local account",
+ PreRunE: func(cmd *cobra.Command, args []string) error {
+ return preRun(preRunArgs{cmd: cmd})
+ },
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return run(cmd.Context(), account.Disable2FA)
+ },
+ }
+ config.AddAdminAccount(adminAccountDisable2FACmd)
+ adminAccountCmd.AddCommand(adminAccountDisable2FACmd)
+
adminCmd.AddCommand(adminAccountCmd)
/*
diff --git a/cmd/gotosocial/testrig.go b/cmd/gotosocial/testrig.go
index 55498243b..4c3d75b3a 100644
--- a/cmd/gotosocial/testrig.go
+++ b/cmd/gotosocial/testrig.go
@@ -20,11 +20,12 @@ package main
import (
"code.superseriousbusiness.org/gotosocial/cmd/gotosocial/action/testrig"
"code.superseriousbusiness.org/gotosocial/internal/config"
+ "codeberg.org/gruf/go-debug"
"github.com/spf13/cobra"
)
func testrigCommands() *cobra.Command {
- if testrig.Start != nil {
+ if debug.DEBUG {
testrigCmd := &cobra.Command{
Use: "testrig",
Short: "gotosocial testrig-related tasks",
diff --git a/internal/processing/user/twofactor.go b/internal/processing/user/twofactor.go
index ec0a3edc8..6fdd8898f 100644
--- a/internal/processing/user/twofactor.go
+++ b/internal/processing/user/twofactor.go
@@ -34,6 +34,7 @@ import (
"code.superseriousbusiness.org/gotosocial/internal/config"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
+ "code.superseriousbusiness.org/gotosocial/internal/state"
"code.superseriousbusiness.org/gotosocial/internal/util"
"codeberg.org/gruf/go-byteutil"
"github.com/pquerna/otp"
@@ -43,60 +44,6 @@ import (
var b32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
-// EncodeQuery is a copy-paste of url.Values.Encode, except it uses
-// %20 instead of + to encode spaces. This is necessary to correctly
-// render spaces in some authenticator apps, like Google Authenticator.
-//
-// [Note: this func and the above comment are both taken
-// directly from github.com/pquerna/otp/internal/encode.go.]
-func encodeQuery(v url.Values) string {
- if v == nil {
- return ""
- }
- var buf strings.Builder
- keys := make([]string, 0, len(v))
- for k := range v {
- keys = append(keys, k)
- }
- sort.Strings(keys)
- for _, k := range keys {
- vs := v[k]
- // Changed from url.QueryEscape.
- keyEscaped := url.PathEscape(k)
- for _, v := range vs {
- if buf.Len() > 0 {
- buf.WriteByte('&')
- }
- buf.WriteString(keyEscaped)
- buf.WriteByte('=')
- // Changed from url.QueryEscape.
- buf.WriteString(url.PathEscape(v))
- }
- }
- return buf.String()
-}
-
-// totpURLForUser reconstructs a TOTP URL for the
-// given user, setting the instance host as issuer.
-//
-// See https://github.com/google/google-authenticator/wiki/Key-Uri-Format
-func totpURLForUser(user *gtsmodel.User) *url.URL {
- issuer := config.GetHost() + " - GoToSocial"
- v := url.Values{}
- v.Set("secret", user.TwoFactorSecret)
- v.Set("issuer", issuer)
- v.Set("period", "30") // 30 seconds totp validity.
- v.Set("algorithm", "SHA1")
- v.Set("digits", "6") // 6-digit totp.
-
- return &url.URL{
- Scheme: "otpauth",
- Host: "totp",
- Path: "/" + issuer + ":" + user.Email,
- RawQuery: encodeQuery(v),
- }
-}
-
func (p *Processor) TwoFactorQRCodePngGet(
ctx context.Context,
user *gtsmodel.User,
@@ -135,14 +82,32 @@ func (p *Processor) TwoFactorQRCodePngGet(
}, nil
}
+// TwoFactorQRCodeURIGet will generate a new
+// 2 factor auth secret for user, and return a
+// URI of expected format for generating a QR code
+// or inputting into a password manager.
+//
+// This may be called multiple times without error
+// UNTIL the moment the user has finalized enabling
+// 2FA. i.e. when user.TwoFactorEnabled() == true.
+// Until this point, the URI may be requested for
+// both QR code generation, and requesting the URI,
+// but once 2FA is confirmed enabled it is not safe
+// to re-share the agreed-upon secret.
func (p *Processor) TwoFactorQRCodeURIGet(
ctx context.Context,
user *gtsmodel.User,
) (*url.URL, gtserror.WithCode) {
- // Check if we need to lazily
- // generate a new 2fa secret.
+ if user.TwoFactorEnabled() {
+ const errText = "2fa already enabled; not sharing secret again"
+ return nil, gtserror.NewErrorConflict(errors.New(errText), errText)
+ }
+
+ // Only generate new 2FA secret
+ // if not already been generated
+ // during this enabling process.
if user.TwoFactorSecret == "" {
- // We do! Read some random crap.
+
// 32 bytes should be plenty entropy.
secret := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
@@ -156,41 +121,42 @@ func (p *Processor) TwoFactorQRCodeURIGet(
err := gtserror.Newf("db error updating user: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
-
- } else if user.TwoFactorEnabled() {
- // If a secret is already set, and 2fa is
- // already enabled, we shouldn't share the
- // secret via QR code again: Someone may
- // have obtained a token for this user and
- // is trying to get the 2fa secret so they
- // can escalate an attack or something.
- const errText = "2fa already enabled; keeping the secret secret"
- return nil, gtserror.NewErrorConflict(errors.New(errText), errText)
}
- // Recreate the totp key.
- return totpURLForUser(user), nil
+ // see: https://github.com/google/google-authenticator/wiki/Key-Uri-Format
+ issuer := config.GetHost() + " - GoToSocial"
+ return &url.URL{
+ Scheme: "otpauth",
+ Host: "totp",
+ Path: "/" + issuer + ":" + user.Email,
+ RawQuery: encodeQuery(url.Values{
+ "secret": {user.TwoFactorSecret},
+ "issuer": {issuer},
+ "period": {"30s"}, // 30s totp validity.
+ "digits": {"6"}, // 6-digit totp.
+ "algorithm": {"SHA1"},
+ }),
+ }, nil
}
+// TwoFactorEnable will enable 2 factor auth for
+// account, using given TOTP code to validate the
+// user's 2fa secret before continuing.
func (p *Processor) TwoFactorEnable(
ctx context.Context,
user *gtsmodel.User,
code string,
) ([]string, gtserror.WithCode) {
- if user.TwoFactorSecret == "" {
- // User doesn't have a secret set, which
- // means they never got the QR code to scan
- // into their authenticator app. We can safely
- // return an error from this request.
- const errText = "no 2fa secret stored yet; read the qr code first"
- return nil, gtserror.NewErrorForbidden(errors.New(errText), errText)
- }
-
if user.TwoFactorEnabled() {
const errText = "2fa already enabled; disable it first then try again"
return nil, gtserror.NewErrorConflict(errors.New(errText), errText)
}
+ if user.TwoFactorSecret == "" {
+ const errText = "no 2fa secret stored; first read qr code / totp secret"
+ return nil, gtserror.NewErrorForbidden(errors.New(errText), errText)
+ }
+
// Try validating the provided code and give
// a helpful error message if it doesn't work.
if !totp.Validate(code, user.TwoFactorSecret) {
@@ -222,12 +188,11 @@ func (p *Processor) TwoFactorEnable(
err := gtserror.Newf("error encrypting backup codes: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
-
user.TwoFactorBackups[i] = string(encryptedBackup)
}
- if err := p.state.DB.UpdateUser(
- ctx,
+ // Update user in the database.
+ if err := p.state.DB.UpdateUser(ctx,
user,
"two_factor_enabled_at",
"two_factor_backups",
@@ -239,16 +204,12 @@ func (p *Processor) TwoFactorEnable(
return backupsClearText, nil
}
+// TwoFactorDisable: see TwoFactorDisable().
func (p *Processor) TwoFactorDisable(
ctx context.Context,
user *gtsmodel.User,
password string,
) gtserror.WithCode {
- if !user.TwoFactorEnabled() {
- const errText = "2fa already disabled"
- return gtserror.NewErrorConflict(errors.New(errText), errText)
- }
-
// Ensure provided password is correct.
if err := bcrypt.CompareHashAndPassword(
byteutil.S2B(user.EncryptedPassword),
@@ -258,13 +219,29 @@ func (p *Processor) TwoFactorDisable(
return gtserror.NewErrorUnauthorized(errors.New(errText), errText)
}
- // Disable 2fa for this user
- // and clear backup codes.
+ // Disable 2 factor auth for this account.
+ return TwoFactorDisable(ctx, p.state, user)
+}
+
+// TwoFactorDisable disables 2 factor auth
+// for given user account. Note this should
+// be gated with password authentication if
+// accessed via web.
+func TwoFactorDisable(
+ ctx context.Context,
+ state *state.State,
+ user *gtsmodel.User,
+) gtserror.WithCode {
+ if !user.TwoFactorEnabled() {
+ const errText = "2fa already disabled"
+ return gtserror.NewErrorConflict(errors.New(errText), errText)
+ }
+
+ // Clear 2FA fields on user account.
user.TwoFactorEnabledAt = time.Time{}
user.TwoFactorSecret = ""
user.TwoFactorBackups = nil
- if err := p.state.DB.UpdateUser(
- ctx,
+ if err := state.DB.UpdateUser(ctx,
user,
"two_factor_enabled_at",
"two_factor_secret",
@@ -276,3 +253,33 @@ func (p *Processor) TwoFactorDisable(
return nil
}
+
+// encodeQuery is a copy-paste of url.Values.Encode, except it uses
+// %20 instead of + to encode spaces. This is necessary to correctly
+// render spaces in some authenticator apps, like Google Authenticator.
+//
+// [Note: this func and the above comment are both taken
+// directly from github.com/pquerna/otp/internal/encode.go.]
+func encodeQuery(v url.Values) string {
+ var buf strings.Builder
+ keys := make([]string, 0, len(v))
+ for k := range v {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ vs := v[k]
+ // Changed from url.QueryEscape.
+ keyEscaped := url.PathEscape(k)
+ for _, v := range vs {
+ if buf.Len() > 0 {
+ buf.WriteByte('&')
+ }
+ buf.WriteString(keyEscaped)
+ buf.WriteByte('=')
+ // Changed from url.QueryEscape.
+ buf.WriteString(url.PathEscape(v))
+ }
+ }
+ return buf.String()
+}