mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
* [WIP] Auth: add backend skipOrgRoleSync to AzureAD OAuth - add: skipOrgRoleSync - rename: skipOrgRoleSync to skipOrgRoleSyncBase (to make it clear that it is the base version of SocialBase) - add: tests for skipOrgRoleSync in AzureAD TODO: - [ ] frontend changes * add: docs * refactor: remove role from basicinfo * add: settings for grafanacom * add: settigns for frontend * add: logic for azureAD user skip org role * add: docs for skip_org_role_sync * refactor: docs a bit * add: tests for userinfo * refactor: to only extract if skiporgrolesync false * refactor: based on review comments * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com> Co-authored-by: Christopher Moyer <35463610+chri2547@users.noreply.github.com>
457 lines
14 KiB
Go
457 lines
14 KiB
Go
package social
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"context"
|
|
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/text/cases"
|
|
"golang.org/x/text/language"
|
|
|
|
"github.com/grafana/grafana/pkg/infra/log"
|
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
|
"github.com/grafana/grafana/pkg/services/org"
|
|
"github.com/grafana/grafana/pkg/setting"
|
|
"github.com/grafana/grafana/pkg/util"
|
|
)
|
|
|
|
var (
|
|
logger = log.New("social")
|
|
)
|
|
|
|
type SocialService struct {
|
|
cfg *setting.Cfg
|
|
|
|
socialMap map[string]SocialConnector
|
|
oAuthProvider map[string]*OAuthInfo
|
|
}
|
|
|
|
type OAuthInfo struct {
|
|
ClientId, ClientSecret string
|
|
Scopes []string
|
|
AuthUrl, TokenUrl string
|
|
Enabled bool
|
|
EmailAttributeName string
|
|
EmailAttributePath string
|
|
RoleAttributePath string
|
|
RoleAttributeStrict bool
|
|
GroupsAttributePath string
|
|
TeamIdsAttributePath string
|
|
AllowedDomains []string
|
|
AllowAssignGrafanaAdmin bool
|
|
HostedDomain string
|
|
ApiUrl string
|
|
TeamsUrl string
|
|
AllowSignup bool
|
|
Name string
|
|
Icon string
|
|
TlsClientCert string
|
|
TlsClientKey string
|
|
TlsClientCa string
|
|
TlsSkipVerify bool
|
|
UsePKCE bool
|
|
}
|
|
|
|
func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureManager) *SocialService {
|
|
ss := SocialService{
|
|
cfg: cfg,
|
|
oAuthProvider: make(map[string]*OAuthInfo),
|
|
socialMap: make(map[string]SocialConnector),
|
|
}
|
|
|
|
for _, name := range allOauthes {
|
|
sec := cfg.Raw.Section("auth." + name)
|
|
|
|
info := &OAuthInfo{
|
|
ClientId: sec.Key("client_id").String(),
|
|
ClientSecret: sec.Key("client_secret").String(),
|
|
Scopes: util.SplitString(sec.Key("scopes").String()),
|
|
AuthUrl: sec.Key("auth_url").String(),
|
|
TokenUrl: sec.Key("token_url").String(),
|
|
ApiUrl: sec.Key("api_url").String(),
|
|
TeamsUrl: sec.Key("teams_url").String(),
|
|
Enabled: sec.Key("enabled").MustBool(),
|
|
EmailAttributeName: sec.Key("email_attribute_name").String(),
|
|
EmailAttributePath: sec.Key("email_attribute_path").String(),
|
|
RoleAttributePath: sec.Key("role_attribute_path").String(),
|
|
RoleAttributeStrict: sec.Key("role_attribute_strict").MustBool(),
|
|
GroupsAttributePath: sec.Key("groups_attribute_path").String(),
|
|
TeamIdsAttributePath: sec.Key("team_ids_attribute_path").String(),
|
|
AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()),
|
|
HostedDomain: sec.Key("hosted_domain").String(),
|
|
AllowSignup: sec.Key("allow_sign_up").MustBool(),
|
|
Name: sec.Key("name").MustString(name),
|
|
Icon: sec.Key("icon").String(),
|
|
TlsClientCert: sec.Key("tls_client_cert").String(),
|
|
TlsClientKey: sec.Key("tls_client_key").String(),
|
|
TlsClientCa: sec.Key("tls_client_ca").String(),
|
|
TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(),
|
|
UsePKCE: sec.Key("use_pkce").MustBool(),
|
|
AllowAssignGrafanaAdmin: sec.Key("allow_assign_grafana_admin").MustBool(false),
|
|
}
|
|
|
|
// when empty_scopes parameter exists and is true, overwrite scope with empty value
|
|
if sec.Key("empty_scopes").MustBool() {
|
|
info.Scopes = []string{}
|
|
}
|
|
|
|
if !info.Enabled {
|
|
continue
|
|
}
|
|
|
|
if name == "grafananet" {
|
|
name = grafanaCom
|
|
}
|
|
|
|
ss.oAuthProvider[name] = info
|
|
|
|
var authStyle oauth2.AuthStyle
|
|
switch strings.ToLower(sec.Key("auth_style").String()) {
|
|
case "inparams":
|
|
authStyle = oauth2.AuthStyleInParams
|
|
case "inheader":
|
|
authStyle = oauth2.AuthStyleInHeader
|
|
case "autodetect", "":
|
|
authStyle = oauth2.AuthStyleAutoDetect
|
|
default:
|
|
logger.Warn("Invalid auth style specified, defaulting to auth style AutoDetect", "auth_style", sec.Key("auth_style").String())
|
|
authStyle = oauth2.AuthStyleAutoDetect
|
|
}
|
|
|
|
config := oauth2.Config{
|
|
ClientID: info.ClientId,
|
|
ClientSecret: info.ClientSecret,
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: info.AuthUrl,
|
|
TokenURL: info.TokenUrl,
|
|
AuthStyle: authStyle,
|
|
},
|
|
RedirectURL: strings.TrimSuffix(cfg.AppURL, "/") + SocialBaseUrl + name,
|
|
Scopes: info.Scopes,
|
|
}
|
|
|
|
// GitHub.
|
|
if name == "github" {
|
|
ss.socialMap["github"] = &SocialGithub{
|
|
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
|
apiUrl: info.ApiUrl,
|
|
teamIds: sec.Key("team_ids").Ints(","),
|
|
allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()),
|
|
}
|
|
}
|
|
|
|
// GitLab.
|
|
if name == "gitlab" {
|
|
ss.socialMap["gitlab"] = &SocialGitlab{
|
|
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
|
apiUrl: info.ApiUrl,
|
|
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
|
|
}
|
|
}
|
|
|
|
// Google.
|
|
if name == "google" {
|
|
ss.socialMap["google"] = &SocialGoogle{
|
|
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
|
hostedDomain: info.HostedDomain,
|
|
apiUrl: info.ApiUrl,
|
|
}
|
|
}
|
|
|
|
// AzureAD.
|
|
if name == "azuread" {
|
|
ss.socialMap["azuread"] = &SocialAzureAD{
|
|
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
|
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
|
|
forceUseGraphAPI: sec.Key("force_use_graph_api").MustBool(false),
|
|
skipOrgRoleSync: cfg.AzureADSkipOrgRoleSync,
|
|
}
|
|
}
|
|
|
|
// Okta
|
|
if name == "okta" {
|
|
ss.socialMap["okta"] = &SocialOkta{
|
|
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
|
apiUrl: info.ApiUrl,
|
|
allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
|
|
}
|
|
}
|
|
|
|
// Generic - Uses the same scheme as GitHub.
|
|
if name == "generic_oauth" {
|
|
ss.socialMap["generic_oauth"] = &SocialGenericOAuth{
|
|
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
|
apiUrl: info.ApiUrl,
|
|
teamsUrl: info.TeamsUrl,
|
|
emailAttributeName: info.EmailAttributeName,
|
|
emailAttributePath: info.EmailAttributePath,
|
|
nameAttributePath: sec.Key("name_attribute_path").String(),
|
|
groupsAttributePath: info.GroupsAttributePath,
|
|
loginAttributePath: sec.Key("login_attribute_path").String(),
|
|
idTokenAttributeName: sec.Key("id_token_attribute_name").String(),
|
|
teamIdsAttributePath: sec.Key("team_ids_attribute_path").String(),
|
|
teamIds: sec.Key("team_ids").Strings(","),
|
|
allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()),
|
|
}
|
|
}
|
|
|
|
if name == grafanaCom {
|
|
config = oauth2.Config{
|
|
ClientID: info.ClientId,
|
|
ClientSecret: info.ClientSecret,
|
|
Endpoint: oauth2.Endpoint{
|
|
AuthURL: cfg.GrafanaComURL + "/oauth2/authorize",
|
|
TokenURL: cfg.GrafanaComURL + "/api/oauth2/token",
|
|
AuthStyle: oauth2.AuthStyleInHeader,
|
|
},
|
|
RedirectURL: strings.TrimSuffix(cfg.AppURL, "/") + SocialBaseUrl + name,
|
|
Scopes: info.Scopes,
|
|
}
|
|
|
|
ss.socialMap[grafanaCom] = &SocialGrafanaCom{
|
|
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
|
|
url: cfg.GrafanaComURL,
|
|
allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()),
|
|
skipOrgRoleSync: cfg.GrafanaComSkipOrgRoleSync,
|
|
}
|
|
}
|
|
}
|
|
return &ss
|
|
}
|
|
|
|
type BasicUserInfo struct {
|
|
Id string
|
|
Name string
|
|
Email string
|
|
Login string
|
|
Role org.RoleType
|
|
IsGrafanaAdmin *bool // nil will avoid overriding user's set server admin setting
|
|
Groups []string
|
|
}
|
|
|
|
func (b *BasicUserInfo) String() string {
|
|
return fmt.Sprintf("Id: %s, Name: %s, Email: %s, Login: %s, Role: %s, Groups: %v",
|
|
b.Id, b.Name, b.Email, b.Login, b.Role, b.Groups)
|
|
}
|
|
|
|
type SocialConnector interface {
|
|
UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error)
|
|
IsEmailAllowed(email string) bool
|
|
IsSignupAllowed() bool
|
|
|
|
AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
|
|
Exchange(ctx context.Context, code string, authOptions ...oauth2.AuthCodeOption) (*oauth2.Token, error)
|
|
Client(ctx context.Context, t *oauth2.Token) *http.Client
|
|
TokenSource(ctx context.Context, t *oauth2.Token) oauth2.TokenSource
|
|
}
|
|
|
|
type SocialBase struct {
|
|
*oauth2.Config
|
|
log log.Logger
|
|
allowSignup bool
|
|
allowAssignGrafanaAdmin bool
|
|
allowedDomains []string
|
|
|
|
roleAttributePath string
|
|
roleAttributeStrict bool
|
|
autoAssignOrgRole string
|
|
skipOrgRoleSync bool
|
|
features featuremgmt.FeatureManager
|
|
}
|
|
|
|
type Error struct {
|
|
s string
|
|
}
|
|
|
|
func (e Error) Error() string {
|
|
return e.s
|
|
}
|
|
|
|
const (
|
|
grafanaCom = "grafana_com"
|
|
RoleGrafanaAdmin = "GrafanaAdmin" // For AzureAD for example this value cannot contain spaces
|
|
)
|
|
|
|
var (
|
|
SocialBaseUrl = "/login/"
|
|
SocialMap = make(map[string]SocialConnector)
|
|
allOauthes = []string{"github", "gitlab", "google", "generic_oauth", "grafananet", grafanaCom, "azuread", "okta"}
|
|
)
|
|
|
|
type Service interface {
|
|
GetOAuthProviders() map[string]bool
|
|
GetOAuthHttpClient(string) (*http.Client, error)
|
|
GetConnector(string) (SocialConnector, error)
|
|
GetOAuthInfoProvider(string) *OAuthInfo
|
|
GetOAuthInfoProviders() map[string]*OAuthInfo
|
|
}
|
|
|
|
func newSocialBase(name string,
|
|
config *oauth2.Config,
|
|
info *OAuthInfo,
|
|
autoAssignOrgRole string,
|
|
skipOrgRoleSync bool,
|
|
features featuremgmt.FeatureManager,
|
|
) *SocialBase {
|
|
logger := log.New("oauth." + name)
|
|
|
|
return &SocialBase{
|
|
Config: config,
|
|
log: logger,
|
|
allowSignup: info.AllowSignup,
|
|
allowAssignGrafanaAdmin: info.AllowAssignGrafanaAdmin,
|
|
allowedDomains: info.AllowedDomains,
|
|
autoAssignOrgRole: autoAssignOrgRole,
|
|
roleAttributePath: info.RoleAttributePath,
|
|
roleAttributeStrict: info.RoleAttributeStrict,
|
|
skipOrgRoleSync: skipOrgRoleSync,
|
|
features: features,
|
|
}
|
|
}
|
|
|
|
type groupStruct struct {
|
|
Groups []string `json:"groups"`
|
|
}
|
|
|
|
func (s *SocialBase) extractRoleAndAdmin(rawJSON []byte, groups []string, legacy bool) (org.RoleType, bool) {
|
|
if s.roleAttributePath == "" {
|
|
return s.defaultRole(legacy), false
|
|
}
|
|
|
|
role, err := s.searchJSONForStringAttr(s.roleAttributePath, rawJSON)
|
|
if err == nil && role != "" {
|
|
return getRoleFromSearch(role)
|
|
}
|
|
|
|
if groupBytes, err := json.Marshal(groupStruct{groups}); err == nil {
|
|
role, err := s.searchJSONForStringAttr(s.roleAttributePath, groupBytes)
|
|
if err == nil && role != "" {
|
|
return getRoleFromSearch(role)
|
|
}
|
|
}
|
|
|
|
return s.defaultRole(legacy), false
|
|
}
|
|
|
|
// defaultRole returns the default role for the user based on the autoAssignOrgRole setting
|
|
// if legacy is enabled "" is returned indicating the previous role assignment is used.
|
|
func (s *SocialBase) defaultRole(legacy bool) org.RoleType {
|
|
if s.roleAttributeStrict {
|
|
s.log.Debug("RoleAttributeStrict is set, returning no role.")
|
|
return ""
|
|
}
|
|
|
|
if s.autoAssignOrgRole != "" && !legacy {
|
|
s.log.Debug("No role found, returning default.")
|
|
return org.RoleType(s.autoAssignOrgRole)
|
|
}
|
|
|
|
if legacy && !s.skipOrgRoleSync {
|
|
s.log.Warn("No valid role found. Skipping role sync. " +
|
|
"In Grafana 10, this will result in the user being assigned the default role and overriding manual assignment. " +
|
|
"If role sync is not desired, set oauth_skip_org_role_update_sync to true")
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// match grafana admin role and translate to org role and bool.
|
|
// treat the JSON search result to ensure correct casing.
|
|
func getRoleFromSearch(role string) (org.RoleType, bool) {
|
|
if strings.EqualFold(role, RoleGrafanaAdmin) {
|
|
return org.RoleAdmin, true
|
|
}
|
|
|
|
return org.RoleType(cases.Title(language.Und).String(role)), false
|
|
}
|
|
|
|
// GetOAuthProviders returns available oauth providers and if they're enabled or not
|
|
func (ss *SocialService) GetOAuthProviders() map[string]bool {
|
|
result := map[string]bool{}
|
|
|
|
if ss.cfg == nil || ss.cfg.Raw == nil {
|
|
return result
|
|
}
|
|
|
|
for _, name := range allOauthes {
|
|
if name == "grafananet" {
|
|
name = grafanaCom
|
|
}
|
|
|
|
sec := ss.cfg.Raw.Section("auth." + name)
|
|
if sec == nil {
|
|
continue
|
|
}
|
|
result[name] = sec.Key("enabled").MustBool()
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (ss *SocialService) GetOAuthHttpClient(name string) (*http.Client, error) {
|
|
// The socialMap keys don't have "oauth_" prefix, but everywhere else in the system does
|
|
name = strings.TrimPrefix(name, "oauth_")
|
|
info, ok := ss.oAuthProvider[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("could not find %q in OAuth Settings", name)
|
|
}
|
|
|
|
// handle call back
|
|
tr := &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: info.TlsSkipVerify,
|
|
},
|
|
}
|
|
oauthClient := &http.Client{
|
|
Transport: tr,
|
|
}
|
|
|
|
if info.TlsClientCert != "" || info.TlsClientKey != "" {
|
|
cert, err := tls.LoadX509KeyPair(info.TlsClientCert, info.TlsClientKey)
|
|
if err != nil {
|
|
logger.Error("Failed to setup TlsClientCert", "oauth", name, "error", err)
|
|
return nil, fmt.Errorf("failed to setup TlsClientCert: %w", err)
|
|
}
|
|
|
|
tr.TLSClientConfig.Certificates = append(tr.TLSClientConfig.Certificates, cert)
|
|
}
|
|
|
|
if info.TlsClientCa != "" {
|
|
caCert, err := os.ReadFile(info.TlsClientCa)
|
|
if err != nil {
|
|
logger.Error("Failed to setup TlsClientCa", "oauth", name, "error", err)
|
|
return nil, fmt.Errorf("failed to setup TlsClientCa: %w", err)
|
|
}
|
|
caCertPool := x509.NewCertPool()
|
|
caCertPool.AppendCertsFromPEM(caCert)
|
|
tr.TLSClientConfig.RootCAs = caCertPool
|
|
}
|
|
return oauthClient, nil
|
|
}
|
|
|
|
func (ss *SocialService) GetConnector(name string) (SocialConnector, error) {
|
|
// The socialMap keys don't have "oauth_" prefix, but everywhere else in the system does
|
|
provider := strings.TrimPrefix(name, "oauth_")
|
|
connector, ok := ss.socialMap[provider]
|
|
if !ok {
|
|
return nil, fmt.Errorf("failed to find oauth provider for %q", name)
|
|
}
|
|
return connector, nil
|
|
}
|
|
|
|
func (ss *SocialService) GetOAuthInfoProvider(name string) *OAuthInfo {
|
|
return ss.oAuthProvider[name]
|
|
}
|
|
|
|
func (ss *SocialService) GetOAuthInfoProviders() map[string]*OAuthInfo {
|
|
return ss.oAuthProvider
|
|
}
|