diff options
Diffstat (limited to 'internal/processing/user/twofactor.go')
| -rw-r--r-- | internal/processing/user/twofactor.go | 285 |
1 files changed, 285 insertions, 0 deletions
diff --git a/internal/processing/user/twofactor.go b/internal/processing/user/twofactor.go new file mode 100644 index 000000000..cbd986b23 --- /dev/null +++ b/internal/processing/user/twofactor.go @@ -0,0 +1,285 @@ +// 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" + + apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" + "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" + "github.com/pquerna/otp/totp" + "golang.org/x/crypto/bcrypt" +) + +var b32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding) + +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 +} + +// 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) { + 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 == "" { + + // 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) + } + } + + // 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": {"30"}, // 30 seconds 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.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) { + 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) + } + + // Update user in the database. + 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 +} + +// TwoFactorDisable: see TwoFactorDisable(). +func (p *Processor) TwoFactorDisable( + ctx context.Context, + user *gtsmodel.User, + password string, +) gtserror.WithCode { + // 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 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 := 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 +} + +// 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() +} |
