github.com/greenpau/go-authcrunch@v1.1.4/pkg/idp/oauth/user.go (about) 1 // Copyright 2022 Paul Greenberg greenpau@outlook.com 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package oauth 16 17 import ( 18 "crypto/hmac" 19 "crypto/sha256" 20 "encoding/hex" 21 "encoding/json" 22 "fmt" 23 "go.uber.org/zap" 24 "io/ioutil" 25 "net/http" 26 "net/url" 27 "strconv" 28 "strings" 29 ) 30 31 type discordMember struct { 32 Roles []string `json:"roles"` 33 } 34 35 type userData struct { 36 Groups []string `json:"groups,omitempty"` 37 } 38 39 func (b *IdentityProvider) fetchGithubUserInfo(params map[string]interface{}) (*userData, error) { 40 var req *http.Request 41 var reqMethod, reqURL, authToken string 42 data := &userData{} 43 reqURL = params["url"].(string) 44 if _, exists := params["method"]; exists { 45 reqMethod = params["method"].(string) 46 } else { 47 reqMethod = "GET" 48 } 49 authToken = params["token"].(string) 50 51 // Create new http client instance. 52 cli, err := b.newBrowser() 53 if err != nil { 54 return nil, err 55 } 56 req, err = http.NewRequest(reqMethod, reqURL, nil) 57 if err != nil { 58 return nil, err 59 } 60 req.Header.Set("Accept", "application/json") 61 req.Header.Add("Authorization", "token "+authToken) 62 63 // Fetch data from the URL. 64 resp, err := cli.Do(req) 65 if err != nil { 66 return nil, err 67 } 68 respBody, err := ioutil.ReadAll(resp.Body) 69 resp.Body.Close() 70 if err != nil { 71 return nil, err 72 } 73 74 b.logger.Debug("Additional user data received", zap.String("url", reqURL), zap.Any("body", respBody)) 75 76 orgs := []map[string]interface{}{} 77 if err := json.Unmarshal(respBody, &orgs); err != nil { 78 return nil, err 79 } 80 for _, org := range orgs { 81 if _, exists := org["login"]; !exists { 82 continue 83 } 84 orgName := org["login"].(string) 85 // Exclude org from processing if it does not match org filters. 86 included := false 87 for _, rp := range b.userOrgFilters { 88 if rp.MatchString(orgName) { 89 included = true 90 break 91 } 92 } 93 if !included { 94 continue 95 } 96 data.Groups = append(data.Groups, fmt.Sprintf("github.com/%s/members", orgName)) 97 } 98 99 b.logger.Debug( 100 "Parsed additional user data", 101 zap.String("url", reqURL), 102 zap.Any("data", data), 103 ) 104 105 return data, nil 106 } 107 108 func (b *IdentityProvider) fetchClaims(tokenData map[string]interface{}) (map[string]interface{}, error) { 109 var userURL string 110 var req *http.Request 111 var err error 112 113 for _, k := range []string{"access_token"} { 114 if _, exists := tokenData[k]; !exists { 115 return nil, fmt.Errorf("token response has no %s field", k) 116 } 117 } 118 119 tokenString := tokenData["access_token"].(string) 120 121 cli, err := b.newBrowser() 122 if err != nil { 123 return nil, err 124 } 125 126 // Configure user info URL. 127 switch b.config.Driver { 128 case "github": 129 userURL = "https://api.github.com/user" 130 case "linkedin": 131 userURL = "https://api.linkedin.com/v2/userinfo" 132 case "gitlab": 133 userURL = b.userInfoURL 134 case "facebook": 135 userURL = "https://graph.facebook.com/me" 136 case "discord": 137 userURL = "https://discord.com/api/v10/users/@me" 138 } 139 140 // Setup http request for the URL. 141 switch b.config.Driver { 142 case "github", "gitlab", "discord", "linkedin": 143 req, err = http.NewRequest("GET", userURL, nil) 144 if err != nil { 145 return nil, err 146 } 147 case "facebook": 148 h := hmac.New(sha256.New, []byte(b.config.ClientSecret)) 149 h.Write([]byte(tokenString)) 150 appSecretProof := hex.EncodeToString(h.Sum(nil)) 151 params := url.Values{} 152 // See https://developers.facebook.com/docs/graph-api/reference/user/ 153 params.Set("fields", "id,first_name,last_name,name,email") 154 params.Set("access_token", tokenString) 155 params.Set("appsecret_proof", appSecretProof) 156 req, err = http.NewRequest("GET", userURL, nil) 157 if err != nil { 158 return nil, err 159 } 160 req.URL.RawQuery = params.Encode() 161 default: 162 return nil, fmt.Errorf("provider %s is unsupported for fetching claims", b.config.Driver) 163 } 164 165 req.Header.Set("Accept", "application/json") 166 167 switch b.config.Driver { 168 case "github": 169 req.Header.Add("Authorization", "token "+tokenString) 170 case "gitlab", "discord", "linkedin": 171 req.Header.Add("Authorization", "Bearer "+tokenString) 172 } 173 174 // Fetch data from the URL. 175 resp, err := cli.Do(req) 176 if err != nil { 177 return nil, err 178 } 179 180 respBody, err := ioutil.ReadAll(resp.Body) 181 resp.Body.Close() 182 if err != nil { 183 return nil, err 184 } 185 186 b.logger.Debug( 187 "User profile received", 188 zap.Any("body", respBody), 189 zap.String("url", userURL), 190 ) 191 192 data := make(map[string]interface{}) 193 if err := json.Unmarshal(respBody, &data); err != nil { 194 return nil, err 195 } 196 197 switch b.config.Driver { 198 case "linkedin": 199 if _, exists := data["sub"]; !exists { 200 return nil, fmt.Errorf("failed obtaining user profile with OAuth 2.0 access token, profile field not found") 201 } 202 case "gitlab": 203 if _, exists := data["profile"]; !exists { 204 return nil, fmt.Errorf("failed obtaining user profile with OAuth 2.0 access token, profile field not found") 205 } 206 case "github": 207 if _, exists := data["message"]; exists { 208 return nil, fmt.Errorf("failed obtaining user profile with OAuth 2.0 access token, error: %s", data["message"].(string)) 209 } 210 if _, exists := data["login"]; !exists { 211 return nil, fmt.Errorf("failed obtaining user profile with OAuth 2.0 access token, login field not found") 212 } 213 case "discord": 214 if _, exists := data["id"]; !exists { 215 return nil, fmt.Errorf("failed obtaining user profile with OAuth 2.0 access token, id field not found") 216 } 217 case "facebook": 218 if _, exists := data["error"]; exists { 219 switch data["error"].(type) { 220 case map[string]interface{}: 221 var fbError strings.Builder 222 errMsg := data["error"].(map[string]interface{}) 223 if v, exists := errMsg["code"]; exists { 224 errCode := strconv.FormatFloat(v.(float64), 'f', 0, 64) 225 fbError.WriteString("code=" + errCode) 226 } 227 for _, k := range []string{"fbtrace_id", "message", "type"} { 228 if v, exists := errMsg[k]; exists { 229 fbError.WriteString(", " + k + "=" + v.(string)) 230 } 231 } 232 return nil, fmt.Errorf("failed obtaining user profile with OAuth 2.0 access token, error: %s", fbError.String()) 233 default: 234 return nil, fmt.Errorf("failed obtaining user profile with OAuth 2.0 access token, error: %v", data["error"]) 235 } 236 } 237 for _, k := range []string{"name", "id"} { 238 if _, exists := data[k]; !exists { 239 return nil, fmt.Errorf("failed obtaining user profile with OAuth 2.0 access token, field %s not found, data: %v", k, data) 240 } 241 } 242 default: 243 return nil, fmt.Errorf("unsupported provider: %s", b.config.Driver) 244 } 245 246 m := make(map[string]interface{}) 247 var userGroups []string 248 m["origin"] = userURL 249 switch b.config.Driver { 250 case "github": 251 if _, exists := data["login"]; exists { 252 switch v := data["login"].(type) { 253 case string: 254 m["sub"] = "github.com/" + v 255 } 256 } 257 if _, exists := data["name"]; exists { 258 switch v := data["name"].(type) { 259 case string: 260 m["name"] = v 261 } 262 } 263 if _, exists := data["avatar_url"]; exists { 264 switch v := data["avatar_url"].(type) { 265 case string: 266 m["picture"] = v 267 } 268 } 269 metadata := make(map[string]interface{}) 270 if v, exists := data["id"]; exists { 271 metadata["id"] = v 272 } 273 m["metadata"] = metadata 274 275 if orgURL, exists := data["organizations_url"]; exists && len(b.userOrgFilters) > 0 { 276 params := map[string]interface{}{ 277 "url": orgURL.(string), 278 "method": "GET", 279 "token": tokenString, 280 "username": data["login"].(string), 281 } 282 userData, err := b.fetchGithubUserInfo(params) 283 if err != nil { 284 b.logger.Error( 285 "Failed extracting user org data", 286 zap.String("identity_provider_name", b.config.Name), 287 zap.Error(err), 288 ) 289 } else { 290 userGroups = append(userGroups, userData.Groups...) 291 b.logger.Debug( 292 "Successfully extracted user org data", 293 zap.String("identity_provider_name", b.config.Name), 294 zap.Any("extracted", userData), 295 ) 296 } 297 } 298 299 b.logger.Debug( 300 "Extracted UserInfo endpoint data", 301 zap.String("identity_provider_name", b.config.Name), 302 zap.Any("inputted", data), 303 zap.Any("extracted", m), 304 ) 305 306 case "gitlab": 307 for _, k := range []string{"name", "picture", "profile", "email"} { 308 if _, exists := data[k]; !exists { 309 continue 310 } 311 switch v := data[k].(type) { 312 case string: 313 switch k { 314 case "profile": 315 m["sub"] = v 316 default: 317 m[k] = v 318 } 319 } 320 } 321 if len(b.userGroupFilters) > 0 { 322 if _, exists := data["groups"]; exists { 323 switch groups := data["groups"].(type) { 324 case []interface{}: 325 for _, v := range groups { 326 switch groupName := v.(type) { 327 case string: 328 for _, rp := range b.userGroupFilters { 329 if !rp.MatchString(groupName) { 330 continue 331 } 332 userGroups = append(userGroups, b.serverName+"/"+groupName) 333 break 334 } 335 } 336 } 337 } 338 } 339 } 340 b.logger.Debug( 341 "Extracted UserInfo endpoint data", 342 zap.String("identity_provider_name", b.config.Name), 343 zap.Any("data", m), 344 ) 345 case "discord": 346 m["sub"] = "discord.com/" + data["id"].(string) 347 m["name"] = data["username"] 348 m["picture"] = fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png", data["id"], data["avatar"]) 349 if _, exists := data["email"]; exists { 350 m["email"] = data["email"] 351 } 352 if b.ScopeExists("guilds") { 353 userData, err := b.fetchDiscordGuilds(tokenString) 354 if err != nil { 355 b.logger.Error( 356 "Failed extracting user guild data", 357 zap.String("identity_provider_name", b.config.Name), 358 zap.Error(err), 359 ) 360 } else { 361 userGroups = append(userGroups, userData.Groups...) 362 } 363 } 364 b.logger.Debug( 365 "Extracted UserInfo endpoint data", 366 zap.String("identity_provider_name", b.config.Name), 367 zap.Any("inputted", data), 368 zap.Any("extracted", m), 369 ) 370 case "facebook": 371 if v, exists := data["email"]; exists { 372 m["email"] = v 373 } 374 m["sub"] = data["id"] 375 m["name"] = data["name"] 376 case "linkedin": 377 for _, k := range []string{"name", "picture", "sub", "email"} { 378 if _, exists := data[k]; !exists { 379 continue 380 } 381 switch v := data[k].(type) { 382 case string: 383 m[k] = v 384 } 385 } 386 } 387 388 if len(userGroups) > 0 { 389 m["groups"] = userGroups 390 } 391 return m, nil 392 } 393 394 func (b *IdentityProvider) fetchDiscordGuilds(authToken string) (*userData, error) { 395 var req *http.Request 396 reqURL := "https://discord.com/api/v10/users/@me/guilds" 397 data := &userData{} 398 399 // Create new http client instance. 400 cli, err := b.newBrowser() 401 if err != nil { 402 return nil, err 403 } 404 405 req, err = http.NewRequest("GET", reqURL, nil) 406 if err != nil { 407 return nil, err 408 } 409 req.Header.Set("Accept", "application/json") 410 req.Header.Add("Authorization", "Bearer "+authToken) 411 412 // Fetch data from the URL. 413 resp, err := cli.Do(req) 414 if err != nil { 415 return nil, err 416 } 417 respBody, err := ioutil.ReadAll(resp.Body) 418 resp.Body.Close() 419 if err != nil { 420 return nil, err 421 } 422 423 b.logger.Debug( 424 "Received user guild infomation", 425 zap.String("url", reqURL), 426 zap.Any("body", respBody), 427 ) 428 429 guilds := []map[string]interface{}{} 430 if err := json.Unmarshal(respBody, &guilds); err != nil { 431 return nil, err 432 } 433 434 for _, guild := range guilds { 435 guildID := guild["id"].(string) 436 // Exclude org from processing if it does not match org filters. 437 included := false 438 for _, rp := range b.userGroupFilters { 439 if rp.MatchString(guildID) { 440 included = true 441 break 442 } 443 } 444 if !included { 445 continue 446 } 447 448 b.logger.Debug( 449 "Checking Guild Permissions", 450 zap.String("guildName", guild["name"].(string)), 451 ) 452 453 // Check if the user has special permissions 454 if _, exists := guild["permissions"]; exists { 455 // Parses to int64 for 32-bit system support 456 perm, err := strconv.ParseInt(guild["permissions"].(string), 10, 64) 457 if err != nil { 458 b.logger.Debug( 459 "Error converting Guild permissions to integer", 460 zap.Any("error", err), 461 ) 462 } else if (perm & 0x08) == 0x08 { // Check for admin privileges 463 data.Groups = append(data.Groups, fmt.Sprintf("discord.com/%s/admins", guildID)) 464 } 465 } 466 467 data.Groups = append(data.Groups, fmt.Sprintf("discord.com/%s/members", guildID)) 468 // Fetch roles information for the guild 469 if b.ScopeExists("guilds.members.read") { 470 reqURL = fmt.Sprintf("https://discord.com/api/v10/users/@me/guilds/%s/member", guildID) 471 req, err = http.NewRequest("GET", reqURL, nil) 472 if err != nil { 473 return nil, err 474 } 475 req.Header.Set("Accept", "application/json") 476 req.Header.Add("Authorization", "Bearer "+authToken) 477 478 resp, err = cli.Do(req) 479 if err != nil { 480 return nil, err 481 } 482 483 respBody, err = ioutil.ReadAll(resp.Body) 484 resp.Body.Close() 485 if err != nil { 486 return nil, err 487 } 488 489 var memberData discordMember 490 if err := json.Unmarshal(respBody, &memberData); err != nil { 491 b.logger.Debug( 492 "Guild Roles request failed", 493 zap.Any("response", respBody), 494 zap.Any("error", err), 495 ) 496 return nil, err 497 } 498 499 for _, roleID := range memberData.Roles { 500 data.Groups = append(data.Groups, fmt.Sprintf("discord.com/%s/role/%s", guildID, roleID)) 501 } 502 } 503 504 b.logger.Debug( 505 "Parsed additional discord user data", 506 zap.String("url", reqURL), 507 zap.Any("data", data), 508 ) 509 } 510 511 return data, nil 512 }