From 365b5753419238bb96bc3f9b744d380ff20cbafc Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Mon, 7 Apr 2025 16:14:41 +0200 Subject: [feature] add TOTP two-factor authentication (2FA) (#3960) * [feature] add TOTP two-factor authentication (2FA) * use byteutil.S2B to avoid allocations when comparing + generating password hashes * don't bother with string conversion for consts * use io.ReadFull * use MustGenerateSecret for backup codes * rename util functions --- internal/processing/user/twofactor.go | 278 ++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 internal/processing/user/twofactor.go (limited to 'internal/processing/user/twofactor.go') 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 . + +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 +} -- cgit v1.2.3