| 
									
										
										
										
											2025-04-07 16:14:41 +02:00
										 |  |  | // 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" | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-04-26 15:34:10 +02:00
										 |  |  | 	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" | 
					
						
							| 
									
										
										
										
											2025-08-13 12:24:40 +02:00
										 |  |  | 	"code.superseriousbusiness.org/gotosocial/internal/state" | 
					
						
							| 
									
										
										
										
											2025-04-26 15:34:10 +02:00
										 |  |  | 	"code.superseriousbusiness.org/gotosocial/internal/util" | 
					
						
							| 
									
										
										
										
											2025-04-07 16:14:41 +02:00
										 |  |  | 	"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 | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-08-13 12:24:40 +02:00
										 |  |  | // 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. | 
					
						
							| 
									
										
										
										
											2025-04-07 16:14:41 +02:00
										 |  |  | func (p *Processor) TwoFactorQRCodeURIGet( | 
					
						
							|  |  |  | 	ctx context.Context, | 
					
						
							|  |  |  | 	user *gtsmodel.User, | 
					
						
							|  |  |  | ) (*url.URL, gtserror.WithCode) { | 
					
						
							| 
									
										
										
										
											2025-08-13 12:24:40 +02:00
										 |  |  | 	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. | 
					
						
							| 
									
										
										
										
											2025-04-07 16:14:41 +02:00
										 |  |  | 	if user.TwoFactorSecret == "" { | 
					
						
							| 
									
										
										
										
											2025-08-13 12:24:40 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-04-07 16:14:41 +02:00
										 |  |  | 		// 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) | 
					
						
							|  |  |  | 		} | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-08-13 12:24:40 +02:00
										 |  |  | 	// 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 | 
					
						
							| 
									
										
										
										
											2025-04-07 16:14:41 +02:00
										 |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-08-13 12:24:40 +02:00
										 |  |  | // TwoFactorEnable will enable 2 factor auth for | 
					
						
							|  |  |  | // account, using given TOTP code to validate the | 
					
						
							|  |  |  | // user's 2fa secret before continuing. | 
					
						
							| 
									
										
										
										
											2025-04-07 16:14:41 +02:00
										 |  |  | 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) | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-08-13 12:24:40 +02:00
										 |  |  | 	if user.TwoFactorSecret == "" { | 
					
						
							|  |  |  | 		const errText = "no 2fa secret stored; first read qr code / totp secret" | 
					
						
							|  |  |  | 		return nil, gtserror.NewErrorForbidden(errors.New(errText), errText) | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-04-07 16:14:41 +02:00
										 |  |  | 	// 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) | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-08-13 12:24:40 +02:00
										 |  |  | 	// Update user in the database. | 
					
						
							|  |  |  | 	if err := p.state.DB.UpdateUser(ctx, | 
					
						
							| 
									
										
										
										
											2025-04-07 16:14:41 +02:00
										 |  |  | 		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 | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-08-13 12:24:40 +02:00
										 |  |  | // TwoFactorDisable: see TwoFactorDisable(). | 
					
						
							| 
									
										
										
										
											2025-04-07 16:14:41 +02:00
										 |  |  | 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) | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-08-13 12:24:40 +02:00
										 |  |  | 	// 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. | 
					
						
							| 
									
										
										
										
											2025-04-07 16:14:41 +02:00
										 |  |  | 	user.TwoFactorEnabledAt = time.Time{} | 
					
						
							|  |  |  | 	user.TwoFactorSecret = "" | 
					
						
							|  |  |  | 	user.TwoFactorBackups = nil | 
					
						
							| 
									
										
										
										
											2025-08-13 12:24:40 +02:00
										 |  |  | 	if err := state.DB.UpdateUser(ctx, | 
					
						
							| 
									
										
										
										
											2025-04-07 16:14:41 +02:00
										 |  |  | 		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 | 
					
						
							|  |  |  | } | 
					
						
							| 
									
										
										
										
											2025-08-13 12:24:40 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | // 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() | 
					
						
							|  |  |  | } |