/* GoToSocial Copyright (C) 2021-2022 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 . */ 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/api" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oidc" "github.com/superseriousbusiness/gotosocial/internal/validate" ) // CallbackGETHandler parses a token from an external auth provider. func (m *Module) CallbackGETHandler(c *gin.Context) { s := sessions.Default(c) // check the query vs session state parameter to mitigate csrf // https://auth0.com/docs/secure/attack-protection/state-parameters returnedInternalState := c.Query(callbackStateParam) if returnedInternalState == "" { m.clearSession(s) err := fmt.Errorf("%s parameter not found on callback query", callbackStateParam) api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } savedInternalStateI := s.Get(sessionInternalState) savedInternalState, ok := savedInternalStateI.(string) if !ok { m.clearSession(s) err := fmt.Errorf("key %s was not found in session", sessionInternalState) api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if returnedInternalState != savedInternalState { m.clearSession(s) err := errors.New("mismatch between callback state and saved state") api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } // retrieve stored claims using code code := c.Query(callbackCodeParam) if code == "" { m.clearSession(s) err := fmt.Errorf("%s parameter not found on callback query", callbackCodeParam) api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } claims, errWithCode := m.idp.HandleCallback(c.Request.Context(), code) if errWithCode != nil { m.clearSession(s) api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) 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) err := fmt.Errorf("key %s was not found in session", sessionClientID) api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGet) return } app := >smodel.Application{} if err := m.db.GetWhere(c.Request.Context(), []db.Where{{Key: sessionClientID, Value: clientID}}, app); err != nil { m.clearSession(s) safe := fmt.Sprintf("application for %s %s could not be retrieved", sessionClientID, clientID) var errWithCode gtserror.WithCode if err == db.ErrNoEntries { errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice) } else { errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice) } api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } user, errWithCode := m.parseUserFromClaims(c.Request.Context(), claims, net.IP(c.ClientIP()), app.ID) if errWithCode != nil { m.clearSession(s) api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } s.Set(sessionUserID, user.ID) if err := s.Save(); err != nil { m.clearSession(s) api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } c.Redirect(http.StatusFound, OauthAuthorizePath) } func (m *Module) parseUserFromClaims(ctx context.Context, claims *oidc.Claims, ip net.IP, appID string) (*gtsmodel.User, gtserror.WithCode) { if claims.Email == "" { err := errors.New("no email returned in claims") return nil, gtserror.NewErrorBadRequest(err, err.Error()) } // see if we already have a user for this email address // if so, we don't need to continue + create one user, err := m.db.GetUserByEmailAddress(ctx, claims.Email) if err == nil { return user, nil } if err != db.ErrNoEntries { err := fmt.Errorf("error checking database for email %s: %s", claims.Email, err) return nil, gtserror.NewErrorInternalError(err) } // maybe we have an unconfirmed user err = m.db.GetWhere(ctx, []db.Where{{Key: "unconfirmed_email", Value: claims.Email}}, user) if err == nil { err := fmt.Errorf("user with email address %s is unconfirmed", claims.Email) return nil, gtserror.NewErrorForbidden(err, err.Error()) } if err != db.ErrNoEntries { err := fmt.Errorf("error checking database for email %s: %s", claims.Email, err) return nil, gtserror.NewErrorInternalError(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, gtserror.NewErrorBadRequest(err) } if !emailAvailable { return nil, gtserror.NewErrorConflict(fmt.Errorf("email address %s is not available", 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 == "" { err := errors.New("no name returned in claims") return nil, gtserror.NewErrorBadRequest(err, err.Error()) } // check if we can just use claims.Name as-is if err = validate.Username(claims.Name); 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 := validate.Username(lower); err != nil { err := fmt.Errorf("couldn't parse a valid username from claims.Name value of %s: %s", claims.Name, err) return nil, gtserror.NewErrorBadRequest(err, err.Error()) } // we managed to get a valid username username = lower } 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++ { usernameAvailable, err := m.db.IsUsernameAvailable(ctx, username+iString) if err != nil { return nil, gtserror.NewErrorInternalError(err) } if usernameAvailable { // no error so we've found a username that works found = true 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. // 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() // Since this user is created via oidc, which has been set up by the admin, we can assume that the account is already // implicitly approved, and that the email address has already been verified: otherwise, we end up in situations where // the admin first approves the user in OIDC, and then has to approve them again in GoToSocial, which doesn't make sense. // // In other words, if a user logs in via OIDC, they should be able to use their account straight away. // // See: https://github.com/superseriousbusiness/gotosocial/issues/357 requireApproval := false emailVerified := true // 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, "", requireApproval, claims.Email, password, ip, "", appID, emailVerified, admin) if err != nil { return nil, gtserror.NewErrorInternalError(err) } return user, nil }