The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/pkg/login/social/connectors/gitlab_oauth.go

349 lines
9.8 KiB

package connectors
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ssosettings"
ssoModels "github.com/grafana/grafana/pkg/services/ssosettings/models"
"github.com/grafana/grafana/pkg/services/ssosettings/validation"
"github.com/grafana/grafana/pkg/setting"
)
const (
groupPerPage = 50
accessLevelGuest = "10"
)
var _ social.SocialConnector = (*SocialGitlab)(nil)
var _ ssosettings.Reloadable = (*SocialGitlab)(nil)
type SocialGitlab struct {
*SocialBase
}
type apiData struct {
ID int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
State string `json:"state"`
Name string `json:"name"`
ConfirmedAt *string `json:"confirmed_at"` // "2020-10-02T09:39:40.882Z"
}
type userData struct {
ID string `json:"sub"`
Login string `json:"preferred_username"`
Email string `json:"email"`
Name string `json:"name"`
Groups []string `json:"groups_direct"`
EmailVerified bool `json:"email_verified"`
Role roletype.RoleType `json:"-"`
IsGrafanaAdmin *bool `json:"-"`
}
func NewGitLabProvider(info *social.OAuthInfo, cfg *setting.Cfg, orgRoleMapper *OrgRoleMapper, ssoSettings ssosettings.Service, features featuremgmt.FeatureToggles) *SocialGitlab {
provider := &SocialGitlab{
SocialBase: newSocialBase(social.GitlabProviderName, orgRoleMapper, info, features, cfg),
}
if features.IsEnabledGlobally(featuremgmt.FlagSsoSettingsApi) {
ssoSettings.RegisterReloadable(social.GitlabProviderName, provider)
}
return provider
}
func (s *SocialGitlab) Validate(ctx context.Context, settings ssoModels.SSOSettings, _ ssoModels.SSOSettings, requester identity.Requester) error {
info, err := CreateOAuthInfoFromKeyValues(settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
err = validateInfo(info, requester)
if err != nil {
return err
}
return validation.Validate(info, requester,
validation.MustBeEmptyValidator(info.AuthUrl, "Auth URL"),
validation.MustBeEmptyValidator(info.TokenUrl, "Token URL"),
validation.MustBeEmptyValidator(info.ApiUrl, "API URL"))
}
func (s *SocialGitlab) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
newInfo, err := CreateOAuthInfoFromKeyValues(settings.Settings)
if err != nil {
return ssosettings.ErrInvalidSettings.Errorf("SSO settings map cannot be converted to OAuthInfo: %v", err)
}
s.reloadMutex.Lock()
defer s.reloadMutex.Unlock()
s.updateInfo(ctx, social.GitlabProviderName, newInfo)
return nil
}
func (s *SocialGitlab) getGroups(ctx context.Context, client *http.Client) []string {
groups := make([]string, 0)
nextPage := new(int)
for *nextPage = 1; nextPage != nil; {
var page []string
page, nextPage = s.getGroupsPage(ctx, client, *nextPage)
groups = append(groups, page...)
}
return groups
}
// getGroupsPage returns groups and link to the next page if response is paginated
func (s *SocialGitlab) getGroupsPage(ctx context.Context, client *http.Client, nextPage int) ([]string, *int) {
type Group struct {
FullPath string `json:"full_path"`
}
groupURL, err := url.JoinPath(s.info.ApiUrl, "/groups")
if err != nil {
s.log.Error("Error joining GitLab API URL", "err", err)
return nil, nil
}
parsedUrl, err := url.Parse(groupURL)
if err != nil {
s.log.Error("Error parsing GitLab API URL", "err", err)
return nil, nil
}
q := parsedUrl.Query()
q.Set("per_page", fmt.Sprintf("%d", groupPerPage))
q.Set("min_access_level", accessLevelGuest)
q.Set("page", fmt.Sprintf("%d", nextPage))
parsedUrl.RawQuery = q.Encode()
response, err := s.httpGet(ctx, client, parsedUrl.String())
if err != nil {
s.log.Error("Error getting groups from GitLab API", "err", err)
return nil, nil
}
respSizeString := response.Headers.Get("X-Total")
respSize := groupPerPage
if respSizeString != "" {
foundSize, err := strconv.Atoi(respSizeString)
if err != nil {
s.log.Warn("Error parsing X-Total header from GitLab API", "err", err)
} else {
respSize = foundSize
}
}
groups := make([]Group, 0, respSize)
if err := json.Unmarshal(response.Body, &groups); err != nil {
s.log.Error("Error parsing JSON from GitLab API", "err", err)
return nil, nil
}
fullPaths := make([]string, len(groups))
for i, group := range groups {
fullPaths[i] = group.FullPath
}
var next *int = nil
nextString := response.Headers.Get("X-Next-Page")
if nextString != "" {
foundNext, err := strconv.Atoi(nextString)
if err != nil {
s.log.Warn("Error parsing X-Next-Page header from GitLab API", "err", err)
} else {
next = &foundNext
}
}
return fullPaths, next
}
func (s *SocialGitlab) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*social.BasicUserInfo, error) {
s.reloadMutex.RLock()
defer s.reloadMutex.RUnlock()
data, err := s.extractFromToken(ctx, client, token)
if err != nil {
return nil, err
}
// fallback to API
if data == nil {
var errAPI error
data, errAPI = s.extractFromAPI(ctx, client, token)
if errAPI != nil {
return nil, errAPI
}
}
userInfo := &social.BasicUserInfo{
Id: data.ID,
Name: data.Name,
Login: data.Login,
Email: data.Email,
Groups: data.Groups,
Role: data.Role,
IsGrafanaAdmin: data.IsGrafanaAdmin,
}
if !s.isGroupMember(data.Groups) {
return nil, errMissingGroupMembership
}
if s.info.AllowAssignGrafanaAdmin && s.info.SkipOrgRoleSync {
s.log.Debug("AllowAssignGrafanaAdmin and skipOrgRoleSync are both set, Grafana Admin role will not be synced, consider setting one or the other")
}
return userInfo, nil
}
func (s *SocialGitlab) extractFromAPI(ctx context.Context, client *http.Client, _ *oauth2.Token) (*userData, error) {
apiResp := &apiData{}
response, err := s.httpGet(ctx, client, s.info.ApiUrl+"/user")
if err != nil {
return nil, fmt.Errorf("Error getting user info: %w", err)
}
if err = json.Unmarshal(response.Body, &apiResp); err != nil {
return nil, fmt.Errorf("error getting user info: %w", err)
}
// check confirmed_at exists and is not null
if apiResp.ConfirmedAt == nil || *apiResp.ConfirmedAt == "" {
return nil, fmt.Errorf("user %s's email is not confirmed", apiResp.Username)
}
if apiResp.State != "active" {
return nil, fmt.Errorf("user %s is inactive", apiResp.Username)
}
idData := &userData{
ID: fmt.Sprintf("%d", apiResp.ID),
Login: apiResp.Username,
Email: apiResp.Email,
Name: apiResp.Name,
Groups: s.getGroups(ctx, client),
}
if !s.info.SkipOrgRoleSync {
var grafanaAdmin bool
role, grafanaAdmin, err := s.extractRoleAndAdmin(response.Body, idData.Groups)
if err != nil {
return nil, err
}
if s.info.AllowAssignGrafanaAdmin {
idData.IsGrafanaAdmin = &grafanaAdmin
}
idData.Role = role
}
if s.cfg.Env == setting.Dev {
s.log.Debug("Resolved ID", "data", fmt.Sprintf("%+v", idData))
}
return idData, nil
}
func (s *SocialGitlab) extractFromToken(ctx context.Context, client *http.Client, token *oauth2.Token) (*userData, error) {
s.log.Debug("Extracting user info from OAuth token")
idToken := token.Extra("id_token")
if idToken == nil {
s.log.Debug("No id_token found, defaulting to API access", "token", token)
return nil, nil
}
rawJSON, err := s.retrieveRawIDToken(idToken)
if err != nil {
s.log.Warn("Error retrieving id_token", "error", err, "token", fmt.Sprintf("%+v", idToken))
return nil, nil
}
s.log.Debug("Received id_token", "raw_json", string(rawJSON))
var data userData
if err := json.Unmarshal(rawJSON, &data); err != nil {
s.log.Warn("Error decoding id_token JSON", "raw_json", string(rawJSON), "error", err)
return nil, nil
}
// check email_verified
if !data.EmailVerified {
return nil, fmt.Errorf("user %s's email is not confirmed", data.Login)
}
userInfo, err := s.retrieveUserInfo(ctx, client)
if err != nil {
s.log.Warn("Error retrieving groups from userinfo. Using only token provided groups", "error", err)
} else {
s.log.Debug("Retrieved groups from userinfo", "sub", userInfo.Sub,
"original_groups", data.Groups, "groups", userInfo.Groups)
data.Groups = userInfo.Groups
}
if !s.info.SkipOrgRoleSync {
role, grafanaAdmin, errRole := s.extractRoleAndAdmin(rawJSON, data.Groups)
if errRole != nil {
return nil, errRole
}
if s.info.AllowAssignGrafanaAdmin {
data.IsGrafanaAdmin = &grafanaAdmin
}
data.Role = role
}
s.log.Debug("Resolved user data", "data", fmt.Sprintf("%+v", data))
return &data, nil
}
type userInfoResponse struct {
Sub string `json:"sub"`
SubLegacy string `json:"sub_legacy"`
Name string `json:"name"`
Nickname string `json:"nickname"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Profile string `json:"profile"`
Picture string `json:"picture"`
Groups []string `json:"groups"`
OwnerGroups []string `json:"https://gitlab.org/claims/groups/owner"`
}
// retrieve and parse /oauth/userinfo
func (s *SocialGitlab) retrieveUserInfo(ctx context.Context, client *http.Client) (*userInfoResponse, error) {
userInfoURL := strings.TrimSuffix(s.Endpoint.AuthURL, "/oauth/authorize") + "/oauth/userinfo"
resp, err := s.httpGet(ctx, client, userInfoURL)
if err != nil {
return nil, err
}
var userInfo userInfoResponse
if err := json.Unmarshal(resp.Body, &userInfo); err != nil {
return nil, err
}
return &userInfo, nil
}