summaryrefslogtreecommitdiff
path: root/internal/processing
diff options
context:
space:
mode:
Diffstat (limited to 'internal/processing')
-rw-r--r--internal/processing/account/move.go5
-rw-r--r--internal/processing/user/email.go6
-rw-r--r--internal/processing/user/password.go8
-rw-r--r--internal/processing/user/password_test.go21
-rw-r--r--internal/processing/user/twofactor.go278
5 files changed, 309 insertions, 9 deletions
diff --git a/internal/processing/account/move.go b/internal/processing/account/move.go
index c8665cf04..734331503 100644
--- a/internal/processing/account/move.go
+++ b/internal/processing/account/move.go
@@ -25,6 +25,7 @@ import (
"slices"
"time"
+ "codeberg.org/gruf/go-byteutil"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
@@ -70,8 +71,8 @@ func (p *Processor) MoveSelf(
}
if err := bcrypt.CompareHashAndPassword(
- []byte(authed.User.EncryptedPassword),
- []byte(form.Password),
+ byteutil.S2B(authed.User.EncryptedPassword),
+ byteutil.S2B(form.Password),
); err != nil {
const text = "invalid password provided in Move request"
return gtserror.NewErrorBadRequest(errors.New(text), text)
diff --git a/internal/processing/user/email.go b/internal/processing/user/email.go
index ea9dbb64c..417c7c341 100644
--- a/internal/processing/user/email.go
+++ b/internal/processing/user/email.go
@@ -23,6 +23,7 @@ import (
"fmt"
"time"
+ "codeberg.org/gruf/go-byteutil"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
@@ -41,7 +42,10 @@ func (p *Processor) EmailChange(
newEmail string,
) (*apimodel.User, gtserror.WithCode) {
// Ensure provided password is correct.
- if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password)); err != nil {
+ if err := bcrypt.CompareHashAndPassword(
+ byteutil.S2B(user.EncryptedPassword),
+ byteutil.S2B(password),
+ ); err != nil {
err := gtserror.Newf("%w", err)
return nil, gtserror.NewErrorUnauthorized(err, "password was incorrect")
}
diff --git a/internal/processing/user/password.go b/internal/processing/user/password.go
index 68bc8ddb5..ead79e209 100644
--- a/internal/processing/user/password.go
+++ b/internal/processing/user/password.go
@@ -20,6 +20,7 @@ package user
import (
"context"
+ "codeberg.org/gruf/go-byteutil"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/validate"
@@ -29,7 +30,10 @@ import (
// PasswordChange processes a password change request for the given user.
func (p *Processor) PasswordChange(ctx context.Context, user *gtsmodel.User, oldPassword string, newPassword string) gtserror.WithCode {
// Ensure provided oldPassword is the correct current password.
- if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(oldPassword)); err != nil {
+ if err := bcrypt.CompareHashAndPassword(
+ byteutil.S2B(user.EncryptedPassword),
+ byteutil.S2B(oldPassword),
+ ); err != nil {
err := gtserror.Newf("%w", err)
return gtserror.NewErrorUnauthorized(err, "old password was incorrect")
}
@@ -48,7 +52,7 @@ func (p *Processor) PasswordChange(ctx context.Context, user *gtsmodel.User, old
// Hash the new password.
encryptedPassword, err := bcrypt.GenerateFromPassword(
- []byte(newPassword),
+ byteutil.S2B(newPassword),
bcrypt.DefaultCost,
)
if err != nil {
diff --git a/internal/processing/user/password_test.go b/internal/processing/user/password_test.go
index ee30558c6..7d45341c0 100644
--- a/internal/processing/user/password_test.go
+++ b/internal/processing/user/password_test.go
@@ -22,6 +22,7 @@ import (
"net/http"
"testing"
+ "codeberg.org/gruf/go-byteutil"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"golang.org/x/crypto/bcrypt"
@@ -37,7 +38,10 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordOK() {
errWithCode := suite.user.PasswordChange(context.Background(), user, "password", "verygoodnewpassword")
suite.NoError(errWithCode)
- err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte("verygoodnewpassword"))
+ err := bcrypt.CompareHashAndPassword(
+ byteutil.S2B(user.EncryptedPassword),
+ byteutil.S2B("verygoodnewpassword"),
+ )
suite.NoError(err)
// get user from the db again
@@ -46,7 +50,10 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordOK() {
suite.NoError(err)
// check the password has changed
- err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("verygoodnewpassword"))
+ err = bcrypt.CompareHashAndPassword(
+ byteutil.S2B(dbUser.EncryptedPassword),
+ byteutil.S2B("verygoodnewpassword"),
+ )
suite.NoError(err)
}
@@ -64,7 +71,10 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordIncorrectOld() {
suite.NoError(err)
// check the password has not changed
- err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password"))
+ err = bcrypt.CompareHashAndPassword(
+ byteutil.S2B(dbUser.EncryptedPassword),
+ byteutil.S2B("password"),
+ )
suite.NoError(err)
}
@@ -82,7 +92,10 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordWeakNew() {
suite.NoError(err)
// check the password has not changed
- err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password"))
+ err = bcrypt.CompareHashAndPassword(
+ byteutil.S2B(dbUser.EncryptedPassword),
+ byteutil.S2B("password"),
+ )
suite.NoError(err)
}
diff --git a/internal/processing/user/twofactor.go b/internal/processing/user/twofactor.go
new file mode 100644
index 000000000..02365f3f1
--- /dev/null
+++ b/internal/processing/user/twofactor.go
@@ -0,0 +1,278 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package user
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/base32"
+ "errors"
+ "image/png"
+ "io"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+
+ "codeberg.org/gruf/go-byteutil"
+ "github.com/pquerna/otp"
+ "github.com/pquerna/otp/totp"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+ "golang.org/x/crypto/bcrypt"
+)
+
+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,
+) (*apimodel.Content, gtserror.WithCode) {
+ // Get the 2FA url for this user.
+ totpURI, errWithCode := p.TwoFactorQRCodeURIGet(ctx, user)
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ key, err := otp.NewKeyFromURL(totpURI.String())
+ if err != nil {
+ err := gtserror.Newf("error creating totp key from url: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Spawn a QR code image from the key.
+ qr, err := key.Image(256, 256)
+ if err != nil {
+ err := gtserror.Newf("error creating qr image from key: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Blat the key into a buffer.
+ buf := new(bytes.Buffer)
+ if err := png.Encode(buf, qr); err != nil {
+ err := gtserror.Newf("error encoding qr image to png: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Return it as our nice content model.
+ return &apimodel.Content{
+ ContentType: "image/png",
+ ContentLength: int64(buf.Len()),
+ Content: io.NopCloser(buf),
+ }, nil
+}
+
+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.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 {
+ err := gtserror.Newf("error generating new secret: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Set + store the secret.
+ user.TwoFactorSecret = b32NoPadding.EncodeToString(secret)
+ if err := p.state.DB.UpdateUser(ctx, user, "two_factor_secret"); err != nil {
+ 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
+}
+
+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)
+ }
+
+ // Try validating the provided code and give
+ // a helpful error message if it doesn't work.
+ if !totp.Validate(code, user.TwoFactorSecret) {
+ const errText = "invalid code provided, you may have been too late, try again; " +
+ "if it keeps not working, pester your admin to check that the server clock is correct"
+ return nil, gtserror.NewErrorForbidden(errors.New(errText), errText)
+ }
+
+ // Valid code was provided so we
+ // should turn 2fa on for this user.
+ user.TwoFactorEnabledAt = time.Now()
+
+ // Create recovery codes in cleartext
+ // to show to the user ONCE ONLY.
+ backupsClearText := make([]string, 8)
+ for i := 0; i < 8; i++ {
+ backupsClearText[i] = util.MustGenerateSecret()
+ }
+
+ // Store only the bcrypt-encrypted
+ // versions of the recovery codes.
+ user.TwoFactorBackups = make([]string, 8)
+ for i, backup := range backupsClearText {
+ encryptedBackup, err := bcrypt.GenerateFromPassword(
+ byteutil.S2B(backup),
+ bcrypt.DefaultCost,
+ )
+ if err != nil {
+ 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,
+ user,
+ "two_factor_enabled_at",
+ "two_factor_backups",
+ ); err != nil {
+ err := gtserror.Newf("db error updating user: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return backupsClearText, nil
+}
+
+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),
+ byteutil.S2B(password),
+ ); err != nil {
+ const errText = "incorrect password"
+ return gtserror.NewErrorUnauthorized(errors.New(errText), errText)
+ }
+
+ // Disable 2fa for this user
+ // and clear backup codes.
+ user.TwoFactorEnabledAt = time.Time{}
+ user.TwoFactorSecret = ""
+ user.TwoFactorBackups = nil
+ if err := p.state.DB.UpdateUser(
+ ctx,
+ user,
+ "two_factor_enabled_at",
+ "two_factor_secret",
+ "two_factor_backups",
+ ); err != nil {
+ err := gtserror.Newf("db error updating user: %w", err)
+ return gtserror.NewErrorInternalError(err)
+ }
+
+ return nil
+}