mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-29 19:52:24 -05:00 
			
		
		
		
	Adds 2FA management to the admin CLI. Also does some CLI refactoring so the functions we pass around are exported functions instead of changeable global variables. closes https://codeberg.org/superseriousbusiness/gotosocial/issues/4320 Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4368 Co-authored-by: kim <grufwub@gmail.com> Co-committed-by: kim <grufwub@gmail.com>
		
			
				
	
	
		
			285 lines
		
	
	
	
		
			8.6 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			285 lines
		
	
	
	
		
			8.6 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // 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":    {"30s"}, // 30s 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()
 | |
| }
 |