1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
|
/*
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/spf13/viper"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"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)
// 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{}
if err := m.db.GetWhere(c.Request.Context(), []db.Where{{Key: sessionClientID, Value: 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 = validate.Username(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 := validate.Username(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++ {
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 += 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
requireApproval := viper.GetBool(config.Keys.AccountsApprovalRequired)
user, err = m.db.NewSignup(ctx, username, "", requireApproval, claims.Email, password, ip, "", appID, claims.EmailVerified, admin)
if err != nil {
return nil, fmt.Errorf("error creating user: %s", err)
}
return user, nil
}
|