mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 05:12:25 -05:00 
			
		
		
		
	* start moving to bun * changing more stuff * more * and yet more * tests passing * seems stable now * more big changes * small fix * little fixes
		
			
				
	
	
		
			223 lines
		
	
	
	
		
			7.4 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			223 lines
		
	
	
	
		
			7.4 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| /*
 | |
|    GoToSocial
 | |
|    Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
 | |
| 
 | |
|    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 auth
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"net"
 | |
| 	"net/http"
 | |
| 	"strconv"
 | |
| 	"strings"
 | |
| 
 | |
| 	"github.com/gin-contrib/sessions"
 | |
| 	"github.com/gin-gonic/gin"
 | |
| 	"github.com/google/uuid"
 | |
| 	"github.com/superseriousbusiness/gotosocial/internal/db"
 | |
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 | |
| 	"github.com/superseriousbusiness/gotosocial/internal/oidc"
 | |
| 	"github.com/superseriousbusiness/gotosocial/internal/util"
 | |
| )
 | |
| 
 | |
| // CallbackGETHandler parses a token from an external auth provider.
 | |
| func (m *Module) CallbackGETHandler(c *gin.Context) {
 | |
| 	s := sessions.Default(c)
 | |
| 
 | |
| 	// first make sure the state set in the cookie is the same as the state returned from the external provider
 | |
| 	state := c.Query(callbackStateParam)
 | |
| 	if state == "" {
 | |
| 		m.clearSession(s)
 | |
| 		c.JSON(http.StatusForbidden, gin.H{"error": "state query not found on callback"})
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	savedStateI := s.Get(sessionState)
 | |
| 	savedState, ok := savedStateI.(string)
 | |
| 	if !ok {
 | |
| 		m.clearSession(s)
 | |
| 		c.JSON(http.StatusForbidden, gin.H{"error": "state not found in session"})
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	if state != savedState {
 | |
| 		m.clearSession(s)
 | |
| 		c.JSON(http.StatusForbidden, gin.H{"error": "state mismatch"})
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	code := c.Query(callbackCodeParam)
 | |
| 
 | |
| 	claims, err := m.idp.HandleCallback(c.Request.Context(), code)
 | |
| 	if err != nil {
 | |
| 		m.clearSession(s)
 | |
| 		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	// We can use the client_id on the session to retrieve info about the app associated with the client_id
 | |
| 	clientID, ok := s.Get(sessionClientID).(string)
 | |
| 	if !ok || clientID == "" {
 | |
| 		m.clearSession(s)
 | |
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": "no client_id found in session during callback"})
 | |
| 		return
 | |
| 	}
 | |
| 	app := >smodel.Application{
 | |
| 		ClientID: clientID,
 | |
| 	}
 | |
| 	if err := m.db.GetWhere(c.Request.Context(), []db.Where{{Key: sessionClientID, Value: app.ClientID}}, app); err != nil {
 | |
| 		m.clearSession(s)
 | |
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("no application found for client id %s", clientID)})
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	user, err := m.parseUserFromClaims(c.Request.Context(), claims, net.IP(c.ClientIP()), app.ID)
 | |
| 	if err != nil {
 | |
| 		m.clearSession(s)
 | |
| 		c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	s.Set(sessionUserID, user.ID)
 | |
| 	if err := s.Save(); err != nil {
 | |
| 		m.clearSession(s)
 | |
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	c.Redirect(http.StatusFound, OauthAuthorizePath)
 | |
| }
 | |
| 
 | |
| func (m *Module) parseUserFromClaims(ctx context.Context, claims *oidc.Claims, ip net.IP, appID string) (*gtsmodel.User, error) {
 | |
| 	if claims.Email == "" {
 | |
| 		return nil, errors.New("no email returned in claims")
 | |
| 	}
 | |
| 
 | |
| 	// see if we already have a user for this email address
 | |
| 	user := >smodel.User{}
 | |
| 	err := m.db.GetWhere(ctx, []db.Where{{Key: "email", Value: claims.Email}}, user)
 | |
| 	if err == nil {
 | |
| 		// we do! so we can just return it
 | |
| 		return user, nil
 | |
| 	}
 | |
| 
 | |
| 	if err != db.ErrNoEntries {
 | |
| 		// we have an actual error in the database
 | |
| 		return nil, fmt.Errorf("error checking database for email %s: %s", claims.Email, err)
 | |
| 	}
 | |
| 
 | |
| 	// maybe we have an unconfirmed user
 | |
| 	err = m.db.GetWhere(ctx, []db.Where{{Key: "unconfirmed_email", Value: claims.Email}}, user)
 | |
| 	if err == nil {
 | |
| 		// user is unconfirmed so return an error
 | |
| 		return nil, fmt.Errorf("user with email address %s is unconfirmed", claims.Email)
 | |
| 	}
 | |
| 
 | |
| 	if err != db.ErrNoEntries {
 | |
| 		// we have an actual error in the database
 | |
| 		return nil, fmt.Errorf("error checking database for email %s: %s", claims.Email, err)
 | |
| 	}
 | |
| 
 | |
| 	// we don't have a confirmed or unconfirmed user with the claimed email address
 | |
| 	// however, because we trust the OIDC provider, we should now create a user + account with the provided claims
 | |
| 
 | |
| 	// check if the email address is available for use; if it's not there's nothing we can so
 | |
| 	emailAvailable, err := m.db.IsEmailAvailable(ctx, claims.Email)
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("email %s not available: %s", claims.Email, err)
 | |
| 	}
 | |
| 	if !emailAvailable {
 | |
| 		return nil, fmt.Errorf("email %s in use", claims.Email)
 | |
| 	}
 | |
| 
 | |
| 	// now we need a username
 | |
| 	var username string
 | |
| 
 | |
| 	// make sure claims.Name is defined since we'll be using that for the username
 | |
| 	if claims.Name == "" {
 | |
| 		return nil, errors.New("no name returned in claims")
 | |
| 	}
 | |
| 
 | |
| 	// check if we can just use claims.Name as-is
 | |
| 	err = util.ValidateUsername(claims.Name)
 | |
| 	if err == nil {
 | |
| 		// the name we have on the claims is already a valid username
 | |
| 		username = claims.Name
 | |
| 	} else {
 | |
| 		// not a valid username so we have to fiddle with it to try to make it valid
 | |
| 		// first trim leading and trailing whitespace
 | |
| 		trimmed := strings.TrimSpace(claims.Name)
 | |
| 		// underscore any spaces in the middle of the name
 | |
| 		underscored := strings.ReplaceAll(trimmed, " ", "_")
 | |
| 		// lowercase the whole thing
 | |
| 		lower := strings.ToLower(underscored)
 | |
| 		// see if this is valid....
 | |
| 		if err := util.ValidateUsername(lower); err == nil {
 | |
| 			// we managed to get a valid username
 | |
| 			username = lower
 | |
| 		} else {
 | |
| 			return nil, fmt.Errorf("couldn't parse a valid username from claims.Name value of %s", claims.Name)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	var iString string
 | |
| 	var found bool
 | |
| 	// if the username isn't available we need to iterate on it until we find one that is
 | |
| 	// we should try to do this in a predictable way so we just keep iterating i by one and trying
 | |
| 	// the username with that number on the end
 | |
| 	//
 | |
| 	// note that for the first iteration, iString is still "" when the check is made, so our first choice
 | |
| 	// is still the raw username with no integer stuck on the end
 | |
| 	for i := 1; !found; i = i + 1 {
 | |
| 		usernameAvailable, err := m.db.IsUsernameAvailable(ctx, username+iString)
 | |
| 		if err != nil {
 | |
| 			return nil, err
 | |
| 		}
 | |
| 		if usernameAvailable {
 | |
| 			// no error so we've found a username that works
 | |
| 			found = true
 | |
| 			username = username + iString
 | |
| 			continue
 | |
| 		}
 | |
| 		iString = strconv.Itoa(i)
 | |
| 	}
 | |
| 
 | |
| 	// check if the user is in any recognised admin groups
 | |
| 	var admin bool
 | |
| 	for _, g := range claims.Groups {
 | |
| 		if strings.EqualFold(g, "admin") || strings.EqualFold(g, "admins") {
 | |
| 			admin = true
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// we still need to set *a* password even if it's not a password the user will end up using, so set something random
 | |
| 	// in this case, we'll just set two uuids on top of each other, which should be long + random enough to baffle any attempts to crack.
 | |
| 	//
 | |
| 	// if the user ever wants to log in using gts password rather than oidc flow, they'll have to request a password reset, which is fine
 | |
| 	password := uuid.NewString() + uuid.NewString()
 | |
| 
 | |
| 	// create the user! this will also create an account and store it in the database so we don't need to do that here
 | |
| 	user, err = m.db.NewSignup(ctx, username, "", m.config.AccountsConfig.RequireApproval, claims.Email, password, ip, "", appID, claims.EmailVerified, admin)
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("error creating user: %s", err)
 | |
| 	}
 | |
| 
 | |
| 	return user, nil
 | |
| 
 | |
| }
 |