diff options
Diffstat (limited to 'internal/processing')
| -rw-r--r-- | internal/processing/account/move.go | 5 | ||||
| -rw-r--r-- | internal/processing/user/email.go | 6 | ||||
| -rw-r--r-- | internal/processing/user/password.go | 8 | ||||
| -rw-r--r-- | internal/processing/user/password_test.go | 21 | ||||
| -rw-r--r-- | internal/processing/user/twofactor.go | 278 |
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 +} |
