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  }