mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 10:52:28 -05:00 
			
		
		
		
	
		
			
	
	
		
			279 lines
		
	
	
	
		
			8.2 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
		
		
			
		
	
	
			279 lines
		
	
	
	
		
			8.2 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" | ||
|  | 
 | ||
|  | 	"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 | ||
|  | } |