summaryrefslogtreecommitdiff
path: root/internal/processing/user/twofactor.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/processing/user/twofactor.go')
-rw-r--r--internal/processing/user/twofactor.go285
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()
+}