summaryrefslogtreecommitdiff
path: root/internal/api/client/auth/callback.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/client/auth/callback.go')
-rw-r--r--internal/api/client/auth/callback.go219
1 files changed, 219 insertions, 0 deletions
diff --git a/internal/api/client/auth/callback.go b/internal/api/client/auth/callback.go
new file mode 100644
index 000000000..8bf2a50b5
--- /dev/null
+++ b/internal/api/client/auth/callback.go
@@ -0,0 +1,219 @@
+/*
+ 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 (
+ "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 := &gtsmodel.Application{
+ ClientID: clientID,
+ }
+ if err := m.db.GetWhere([]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(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(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 := &gtsmodel.User{}
+ err := m.db.GetWhere([]db.Where{{Key: "email", Value: claims.Email}}, user)
+ if err == nil {
+ // we do! so we can just return it
+ return user, nil
+ }
+
+ if _, ok := err.(db.ErrNoEntries); !ok {
+ // 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([]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 _, ok := err.(db.ErrNoEntries); !ok {
+ // 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
+ if err := m.db.IsEmailAvailable(claims.Email); err != nil {
+ return nil, fmt.Errorf("email %s not available: %s", claims.Email, err)
+ }
+
+ // 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 {
+ if err := m.db.IsUsernameAvailable(username + iString); err != nil {
+ if strings.Contains(err.Error(), "db error") {
+ // if there's an actual db error we should return
+ return nil, fmt.Errorf("error checking username availability: %s", err)
+ }
+ } else {
+ // 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(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
+
+}