| 
									
										
										
										
											2021-07-23 10:36:28 +02:00
										 |  |  | /* | 
					
						
							|  |  |  |    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 ( | 
					
						
							| 
									
										
										
										
											2021-08-25 15:34:33 +02:00
										 |  |  | 	"context" | 
					
						
							| 
									
										
										
										
											2021-07-23 10:36:28 +02:00
										 |  |  | 	"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, | 
					
						
							|  |  |  | 	} | 
					
						
							| 
									
										
										
										
											2021-08-25 15:34:33 +02:00
										 |  |  | 	if err := m.db.GetWhere(c.Request.Context(), []db.Where{{Key: sessionClientID, Value: app.ClientID}}, app); err != nil { | 
					
						
							| 
									
										
										
										
											2021-07-23 10:36:28 +02:00
										 |  |  | 		m.clearSession(s) | 
					
						
							|  |  |  | 		c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("no application found for client id %s", clientID)}) | 
					
						
							|  |  |  | 		return | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-08-25 15:34:33 +02:00
										 |  |  | 	user, err := m.parseUserFromClaims(c.Request.Context(), claims, net.IP(c.ClientIP()), app.ID) | 
					
						
							| 
									
										
										
										
											2021-07-23 10:36:28 +02:00
										 |  |  | 	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) | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-08-25 15:34:33 +02:00
										 |  |  | func (m *Module) parseUserFromClaims(ctx context.Context, claims *oidc.Claims, ip net.IP, appID string) (*gtsmodel.User, error) { | 
					
						
							| 
									
										
										
										
											2021-07-23 10:36:28 +02:00
										 |  |  | 	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{} | 
					
						
							| 
									
										
										
										
											2021-08-25 15:34:33 +02:00
										 |  |  | 	err := m.db.GetWhere(ctx, []db.Where{{Key: "email", Value: claims.Email}}, user) | 
					
						
							| 
									
										
										
										
											2021-07-23 10:36:28 +02:00
										 |  |  | 	if err == nil { | 
					
						
							|  |  |  | 		// we do! so we can just return it | 
					
						
							|  |  |  | 		return user, nil | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-08-20 12:26:56 +02:00
										 |  |  | 	if err != db.ErrNoEntries { | 
					
						
							| 
									
										
										
										
											2021-07-23 10:36:28 +02:00
										 |  |  | 		// 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 | 
					
						
							| 
									
										
										
										
											2021-08-25 15:34:33 +02:00
										 |  |  | 	err = m.db.GetWhere(ctx, []db.Where{{Key: "unconfirmed_email", Value: claims.Email}}, user) | 
					
						
							| 
									
										
										
										
											2021-07-23 10:36:28 +02:00
										 |  |  | 	if err == nil { | 
					
						
							|  |  |  | 		// user is unconfirmed so return an error | 
					
						
							|  |  |  | 		return nil, fmt.Errorf("user with email address %s is unconfirmed", claims.Email) | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-08-20 12:26:56 +02:00
										 |  |  | 	if err != db.ErrNoEntries { | 
					
						
							| 
									
										
										
										
											2021-07-23 10:36:28 +02:00
										 |  |  | 		// 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 | 
					
						
							| 
									
										
										
										
											2021-08-25 15:34:33 +02:00
										 |  |  | 	emailAvailable, err := m.db.IsEmailAvailable(ctx, claims.Email) | 
					
						
							|  |  |  | 	if err != nil { | 
					
						
							| 
									
										
										
										
											2021-07-23 10:36:28 +02:00
										 |  |  | 		return nil, fmt.Errorf("email %s not available: %s", claims.Email, err) | 
					
						
							|  |  |  | 	} | 
					
						
							| 
									
										
										
										
											2021-08-25 15:34:33 +02:00
										 |  |  | 	if !emailAvailable { | 
					
						
							|  |  |  | 		return nil, fmt.Errorf("email %s in use", claims.Email) | 
					
						
							|  |  |  | 	} | 
					
						
							| 
									
										
										
										
											2021-07-23 10:36:28 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 	// 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 { | 
					
						
							| 
									
										
										
										
											2021-08-25 15:34:33 +02:00
										 |  |  | 		usernameAvailable, err := m.db.IsUsernameAvailable(ctx, username+iString) | 
					
						
							|  |  |  | 		if err != nil { | 
					
						
							|  |  |  | 			return nil, err | 
					
						
							|  |  |  | 		} | 
					
						
							|  |  |  | 		if usernameAvailable { | 
					
						
							| 
									
										
										
										
											2021-07-23 10:36:28 +02:00
										 |  |  | 			// 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 | 
					
						
							| 
									
										
										
										
											2021-08-25 15:34:33 +02:00
										 |  |  | 	user, err = m.db.NewSignup(ctx, username, "", m.config.AccountsConfig.RequireApproval, claims.Email, password, ip, "", appID, claims.EmailVerified, admin) | 
					
						
							| 
									
										
										
										
											2021-07-23 10:36:28 +02:00
										 |  |  | 	if err != nil { | 
					
						
							|  |  |  | 		return nil, fmt.Errorf("error creating user: %s", err) | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	return user, nil | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | } |