diff --git a/conf/defaults.ini b/conf/defaults.ini index 2219b56a77d..6fc26743e1e 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -59,7 +59,7 @@ cert_key = #################################### Database #################################### [database] -# You can configure the database connection by specifying type, host, name, user and password +# You can configure the database connection by specifying type, host, name, user and password # as seperate properties or as on string using the url propertie. # Either "mysql", "postgres" or "sqlite3", it's your choice @@ -223,6 +223,19 @@ token_url = https://accounts.google.com/o/oauth2/token api_url = https://www.googleapis.com/oauth2/v1/userinfo allowed_domains = +#################################### Generic OAuth ########################## +[auth.generic_oauth] +enabled = false +allow_sign_up = false +client_id = some_id +client_secret = some_secret +scopes = user:email +auth_url = +token_url = +api_url = +team_ids = +allowed_organizations = + #################################### Basic Auth ########################## [auth.basic] enabled = true diff --git a/conf/sample.ini b/conf/sample.ini index 949f64df025..2c428ea775f 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -61,7 +61,7 @@ #################################### Database #################################### [database] -# You can configure the database connection by specifying type, host, name, user and password +# You can configure the database connection by specifying type, host, name, user and password # as seperate properties or as on string using the url propertie. # Either "mysql", "postgres" or "sqlite3", it's your choice @@ -205,6 +205,19 @@ check_for_updates = true ;api_url = https://www.googleapis.com/oauth2/v1/userinfo ;allowed_domains = +#################################### Generic OAuth ########################## +[auth.generic_oauth] +;enabled = false +;allow_sign_up = false +;client_id = some_id +;client_secret = some_secret +;scopes = user:email,read:org +;auth_url = https://foo.bar/login/oauth/authorize +;token_url = https://foo.bar/login/oauth/access_token +;api_url = https://foo.bar/user +;team_ids = +;allowed_organizations = + #################################### Auth Proxy ########################## [auth.proxy] ;enabled = false diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 4bb4d262d46..509695a273a 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -341,6 +341,23 @@ You may allow users to sign-up via Google authentication by setting the user successfully authenticating via Google authentication will be automatically signed up. +## [auth.generic_oauth] + +This option could be used if have your own oauth service. + +This callback URL must match the full HTTP address that you use in your +browser to access Grafana, but with the prefix path of `/login/generic_oauth`. + + [auth.generic_oauth] + enabled = true + client_id = YOUR_APP_CLIENT_ID + client_secret = YOUR_APP_CLIENT_SECRET + scopes = + auth_url = + token_url = + allowed_domains = mycompany.com mycompany.org + allow_sign_up = false +
## [auth.basic] diff --git a/pkg/api/login.go b/pkg/api/login.go index 4f976f753a2..789765ee01e 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -27,6 +27,8 @@ func LoginView(c *middleware.Context) { viewData.Settings["googleAuthEnabled"] = setting.OAuthService.Google viewData.Settings["githubAuthEnabled"] = setting.OAuthService.GitHub + viewData.Settings["genericOAuthEnabled"] = setting.OAuthService.Generic + viewData.Settings["oauthProviderName"] = setting.OAuthService.OAuthProviderName viewData.Settings["disableUserSignUp"] = !setting.AllowUserSignUp viewData.Settings["loginHint"] = setting.LoginHint viewData.Settings["allowUserPassLogin"] = setting.AllowUserPassLogin diff --git a/pkg/models/models.go b/pkg/models/models.go index 189e594576b..5a53cfdabb3 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -6,4 +6,5 @@ const ( GITHUB OAuthType = iota + 1 GOOGLE TWITTER + GENERIC ) diff --git a/pkg/setting/setting_oauth.go b/pkg/setting/setting_oauth.go index db2f0fb3802..71c4ade1468 100644 --- a/pkg/setting/setting_oauth.go +++ b/pkg/setting/setting_oauth.go @@ -11,8 +11,9 @@ type OAuthInfo struct { } type OAuther struct { - GitHub, Google, Twitter bool - OAuthInfos map[string]*OAuthInfo + GitHub, Google, Twitter, Generic bool + OAuthInfos map[string]*OAuthInfo + OAuthProviderName string } var OAuthService *OAuther diff --git a/pkg/social/common.go b/pkg/social/common.go new file mode 100644 index 00000000000..7bce5d2ae8f --- /dev/null +++ b/pkg/social/common.go @@ -0,0 +1,20 @@ +package social + +import ( + "fmt" + "strings" +) + +func isEmailAllowed(email string, allowedDomains []string) bool { + if len(allowedDomains) == 0 { + return true + } + + valid := false + for _, domain := range allowedDomains { + emailSuffix := fmt.Sprintf("@%s", domain) + valid = valid || strings.HasSuffix(email, emailSuffix) + } + + return valid +} diff --git a/pkg/social/generic_oauth.go b/pkg/social/generic_oauth.go new file mode 100644 index 00000000000..f016c87e201 --- /dev/null +++ b/pkg/social/generic_oauth.go @@ -0,0 +1,205 @@ +package social + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/grafana/grafana/pkg/models" + + "golang.org/x/oauth2" +) + +type GenericOAuth struct { + *oauth2.Config + allowedDomains []string + allowedOrganizations []string + apiUrl string + allowSignup bool + teamIds []int +} + +func (s *GenericOAuth) Type() int { + return int(models.GENERIC) +} + +func (s *GenericOAuth) IsEmailAllowed(email string) bool { + return isEmailAllowed(email, s.allowedDomains) +} + +func (s *GenericOAuth) IsSignupAllowed() bool { + return s.allowSignup +} + +func (s *GenericOAuth) IsTeamMember(client *http.Client) bool { + if len(s.teamIds) == 0 { + return true + } + + teamMemberships, err := s.FetchTeamMemberships(client) + if err != nil { + return false + } + + for _, teamId := range s.teamIds { + for _, membershipId := range teamMemberships { + if teamId == membershipId { + return true + } + } + } + + return false +} + +func (s *GenericOAuth) IsOrganizationMember(client *http.Client) bool { + if len(s.allowedOrganizations) == 0 { + return true + } + + organizations, err := s.FetchOrganizations(client) + if err != nil { + return false + } + + for _, allowedOrganization := range s.allowedOrganizations { + for _, organization := range organizations { + if organization == allowedOrganization { + return true + } + } + } + + return false +} + +func (s *GenericOAuth) FetchPrivateEmail(client *http.Client) (string, error) { + type Record struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + } + + emailsUrl := fmt.Sprintf(s.apiUrl + "/emails") + r, err := client.Get(emailsUrl) + if err != nil { + return "", err + } + + defer r.Body.Close() + + var records []Record + + if err = json.NewDecoder(r.Body).Decode(&records); err != nil { + return "", err + } + + var email = "" + for _, record := range records { + if record.Primary { + email = record.Email + } + } + + return email, nil +} + +func (s *GenericOAuth) FetchTeamMemberships(client *http.Client) ([]int, error) { + type Record struct { + Id int `json:"id"` + } + + membershipUrl := fmt.Sprintf(s.apiUrl + "/teams") + r, err := client.Get(membershipUrl) + if err != nil { + return nil, err + } + + defer r.Body.Close() + + var records []Record + + if err = json.NewDecoder(r.Body).Decode(&records); err != nil { + return nil, err + } + + var ids = make([]int, len(records)) + for i, record := range records { + ids[i] = record.Id + } + + return ids, nil +} + +func (s *GenericOAuth) FetchOrganizations(client *http.Client) ([]string, error) { + type Record struct { + Login string `json:"login"` + } + + url := fmt.Sprintf(s.apiUrl + "/orgs") + r, err := client.Get(url) + if err != nil { + return nil, err + } + + defer r.Body.Close() + + var records []Record + + if err = json.NewDecoder(r.Body).Decode(&records); err != nil { + return nil, err + } + + var logins = make([]string, len(records)) + for i, record := range records { + logins[i] = record.Login + } + + return logins, nil +} + +func (s *GenericOAuth) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { + var data struct { + Id int `json:"id"` + Name string `json:"login"` + Email string `json:"email"` + } + + var err error + client := s.Client(oauth2.NoContext, token) + r, err := client.Get(s.apiUrl) + if err != nil { + return nil, err + } + + defer r.Body.Close() + + if err = json.NewDecoder(r.Body).Decode(&data); err != nil { + return nil, err + } + + userInfo := &BasicUserInfo{ + Identity: strconv.Itoa(data.Id), + Name: data.Name, + Email: data.Email, + } + + if !s.IsTeamMember(client) { + return nil, errors.New("User not a member of one of the required teams") + } + + if !s.IsOrganizationMember(client) { + return nil, errors.New("User not a member of one of the required organizations") + } + + if userInfo.Email == "" { + userInfo.Email, err = s.FetchPrivateEmail(client) + if err != nil { + return nil, err + } + } + + return userInfo, nil +} diff --git a/pkg/social/github_oauth.go b/pkg/social/github_oauth.go new file mode 100644 index 00000000000..40c8f2a2f7c --- /dev/null +++ b/pkg/social/github_oauth.go @@ -0,0 +1,213 @@ +package social + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/grafana/grafana/pkg/models" + + "golang.org/x/oauth2" +) + +type SocialGithub struct { + *oauth2.Config + allowedDomains []string + allowedOrganizations []string + apiUrl string + allowSignup bool + teamIds []int +} + +var ( + ErrMissingTeamMembership = errors.New("User not a member of one of the required teams") +) + +var ( + ErrMissingOrganizationMembership = errors.New("User not a member of one of the required organizations") +) + +func (s *SocialGithub) Type() int { + return int(models.GITHUB) +} + +func (s *SocialGithub) IsEmailAllowed(email string) bool { + return isEmailAllowed(email, s.allowedDomains) +} + +func (s *SocialGithub) IsSignupAllowed() bool { + return s.allowSignup +} + +func (s *SocialGithub) IsTeamMember(client *http.Client) bool { + if len(s.teamIds) == 0 { + return true + } + + teamMemberships, err := s.FetchTeamMemberships(client) + if err != nil { + return false + } + + for _, teamId := range s.teamIds { + for _, membershipId := range teamMemberships { + if teamId == membershipId { + return true + } + } + } + + return false +} + +func (s *SocialGithub) IsOrganizationMember(client *http.Client) bool { + if len(s.allowedOrganizations) == 0 { + return true + } + + organizations, err := s.FetchOrganizations(client) + if err != nil { + return false + } + + for _, allowedOrganization := range s.allowedOrganizations { + for _, organization := range organizations { + if organization == allowedOrganization { + return true + } + } + } + + return false +} + +func (s *SocialGithub) FetchPrivateEmail(client *http.Client) (string, error) { + type Record struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + } + + emailsUrl := fmt.Sprintf(s.apiUrl + "/emails") + r, err := client.Get(emailsUrl) + if err != nil { + return "", err + } + + defer r.Body.Close() + + var records []Record + + if err = json.NewDecoder(r.Body).Decode(&records); err != nil { + return "", err + } + + var email = "" + for _, record := range records { + if record.Primary { + email = record.Email + } + } + + return email, nil +} + +func (s *SocialGithub) FetchTeamMemberships(client *http.Client) ([]int, error) { + type Record struct { + Id int `json:"id"` + } + + membershipUrl := fmt.Sprintf(s.apiUrl + "/teams") + r, err := client.Get(membershipUrl) + if err != nil { + return nil, err + } + + defer r.Body.Close() + + var records []Record + + if err = json.NewDecoder(r.Body).Decode(&records); err != nil { + return nil, err + } + + var ids = make([]int, len(records)) + for i, record := range records { + ids[i] = record.Id + } + + return ids, nil +} + +func (s *SocialGithub) FetchOrganizations(client *http.Client) ([]string, error) { + type Record struct { + Login string `json:"login"` + } + + url := fmt.Sprintf(s.apiUrl + "/orgs") + r, err := client.Get(url) + if err != nil { + return nil, err + } + + defer r.Body.Close() + + var records []Record + + if err = json.NewDecoder(r.Body).Decode(&records); err != nil { + return nil, err + } + + var logins = make([]string, len(records)) + for i, record := range records { + logins[i] = record.Login + } + + return logins, nil +} + +func (s *SocialGithub) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { + var data struct { + Id int `json:"id"` + Name string `json:"login"` + Email string `json:"email"` + } + + var err error + client := s.Client(oauth2.NoContext, token) + r, err := client.Get(s.apiUrl) + if err != nil { + return nil, err + } + + defer r.Body.Close() + + if err = json.NewDecoder(r.Body).Decode(&data); err != nil { + return nil, err + } + + userInfo := &BasicUserInfo{ + Identity: strconv.Itoa(data.Id), + Name: data.Name, + Email: data.Email, + } + + if !s.IsTeamMember(client) { + return nil, ErrMissingTeamMembership + } + + if !s.IsOrganizationMember(client) { + return nil, ErrMissingOrganizationMembership + } + + if userInfo.Email == "" { + userInfo.Email, err = s.FetchPrivateEmail(client) + if err != nil { + return nil, err + } + } + + return userInfo, nil +} diff --git a/pkg/social/google_oauth.go b/pkg/social/google_oauth.go new file mode 100644 index 00000000000..7f0fdcc250a --- /dev/null +++ b/pkg/social/google_oauth.go @@ -0,0 +1,52 @@ +package social + +import ( + "encoding/json" + + "github.com/grafana/grafana/pkg/models" + + "golang.org/x/oauth2" +) + +type SocialGoogle struct { + *oauth2.Config + allowedDomains []string + apiUrl string + allowSignup bool +} + +func (s *SocialGoogle) Type() int { + return int(models.GOOGLE) +} + +func (s *SocialGoogle) IsEmailAllowed(email string) bool { + return isEmailAllowed(email, s.allowedDomains) +} + +func (s *SocialGoogle) IsSignupAllowed() bool { + return s.allowSignup +} + +func (s *SocialGoogle) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { + var data struct { + Id string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } + var err error + + client := s.Client(oauth2.NoContext, token) + r, err := client.Get(s.apiUrl) + if err != nil { + return nil, err + } + defer r.Body.Close() + if err = json.NewDecoder(r.Body).Decode(&data); err != nil { + return nil, err + } + return &BasicUserInfo{ + Identity: data.Id, + Name: data.Name, + Email: data.Email, + }, nil +} diff --git a/pkg/social/social.go b/pkg/social/social.go index 4a8cb8bac5d..66d0f5fa778 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -1,14 +1,8 @@ package social import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" "strings" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" "golang.org/x/net/context" @@ -42,7 +36,7 @@ func NewOAuthService() { setting.OAuthService = &setting.OAuther{} setting.OAuthService.OAuthInfos = make(map[string]*setting.OAuthInfo) - allOauthes := []string{"github", "google"} + allOauthes := []string{"github", "google", "generic_oauth"} for _, name := range allOauthes { sec := setting.Cfg.Section("auth." + name) @@ -98,269 +92,21 @@ func NewOAuthService() { allowSignup: info.AllowSignup, } } - } -} -func isEmailAllowed(email string, allowedDomains []string) bool { - if len(allowedDomains) == 0 { - return true - } - - valid := false - for _, domain := range allowedDomains { - emailSuffix := fmt.Sprintf("@%s", domain) - valid = valid || strings.HasSuffix(email, emailSuffix) - } - - return valid -} - -type SocialGithub struct { - *oauth2.Config - allowedDomains []string - allowedOrganizations []string - apiUrl string - allowSignup bool - teamIds []int -} - -var ( - ErrMissingTeamMembership = errors.New("User not a member of one of the required teams") -) - -var ( - ErrMissingOrganizationMembership = errors.New("User not a member of one of the required organizations") -) - -func (s *SocialGithub) Type() int { - return int(models.GITHUB) -} - -func (s *SocialGithub) IsEmailAllowed(email string) bool { - return isEmailAllowed(email, s.allowedDomains) -} - -func (s *SocialGithub) IsSignupAllowed() bool { - return s.allowSignup -} - -func (s *SocialGithub) IsTeamMember(client *http.Client) bool { - if len(s.teamIds) == 0 { - return true - } - - teamMemberships, err := s.FetchTeamMemberships(client) - if err != nil { - return false - } - - for _, teamId := range s.teamIds { - for _, membershipId := range teamMemberships { - if teamId == membershipId { - return true + // Generic - Uses the same scheme as Github. + if name == "generic_oauth" { + setting.OAuthService.Generic = true + setting.OAuthService.OAuthProviderName = sec.Key("oauth_provider_name").String() + teamIds := sec.Key("team_ids").Ints(",") + allowedOrganizations := sec.Key("allowed_organizations").Strings(" ") + SocialMap["generic_oauth"] = &GenericOAuth{ + Config: &config, + allowedDomains: info.AllowedDomains, + apiUrl: info.ApiUrl, + allowSignup: info.AllowSignup, + teamIds: teamIds, + allowedOrganizations: allowedOrganizations, } } } - - return false -} - -func (s *SocialGithub) IsOrganizationMember(client *http.Client) bool { - if len(s.allowedOrganizations) == 0 { - return true - } - - organizations, err := s.FetchOrganizations(client) - if err != nil { - return false - } - - for _, allowedOrganization := range s.allowedOrganizations { - for _, organization := range organizations { - if organization == allowedOrganization { - return true - } - } - } - - return false -} - -func (s *SocialGithub) FetchPrivateEmail(client *http.Client) (string, error) { - type Record struct { - Email string `json:"email"` - Primary bool `json:"primary"` - Verified bool `json:"verified"` - } - - emailsUrl := fmt.Sprintf(s.apiUrl + "/emails") - r, err := client.Get(emailsUrl) - if err != nil { - return "", err - } - - defer r.Body.Close() - - var records []Record - - if err = json.NewDecoder(r.Body).Decode(&records); err != nil { - return "", err - } - - var email = "" - for _, record := range records { - if record.Primary { - email = record.Email - } - } - - return email, nil -} - -func (s *SocialGithub) FetchTeamMemberships(client *http.Client) ([]int, error) { - type Record struct { - Id int `json:"id"` - } - - membershipUrl := fmt.Sprintf(s.apiUrl + "/teams") - r, err := client.Get(membershipUrl) - if err != nil { - return nil, err - } - - defer r.Body.Close() - - var records []Record - - if err = json.NewDecoder(r.Body).Decode(&records); err != nil { - return nil, err - } - - var ids = make([]int, len(records)) - for i, record := range records { - ids[i] = record.Id - } - - return ids, nil -} - -func (s *SocialGithub) FetchOrganizations(client *http.Client) ([]string, error) { - type Record struct { - Login string `json:"login"` - } - - url := fmt.Sprintf(s.apiUrl + "/orgs") - r, err := client.Get(url) - if err != nil { - return nil, err - } - - defer r.Body.Close() - - var records []Record - - if err = json.NewDecoder(r.Body).Decode(&records); err != nil { - return nil, err - } - - var logins = make([]string, len(records)) - for i, record := range records { - logins[i] = record.Login - } - - return logins, nil -} - -func (s *SocialGithub) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { - var data struct { - Id int `json:"id"` - Name string `json:"login"` - Email string `json:"email"` - } - - var err error - client := s.Client(oauth2.NoContext, token) - r, err := client.Get(s.apiUrl) - if err != nil { - return nil, err - } - - defer r.Body.Close() - - if err = json.NewDecoder(r.Body).Decode(&data); err != nil { - return nil, err - } - - userInfo := &BasicUserInfo{ - Identity: strconv.Itoa(data.Id), - Name: data.Name, - Email: data.Email, - } - - if !s.IsTeamMember(client) { - return nil, ErrMissingTeamMembership - } - - if !s.IsOrganizationMember(client) { - return nil, ErrMissingOrganizationMembership - } - - if userInfo.Email == "" { - userInfo.Email, err = s.FetchPrivateEmail(client) - if err != nil { - return nil, err - } - } - - return userInfo, nil -} - -// ________ .__ -// / _____/ ____ ____ ____ | | ____ -// / \ ___ / _ \ / _ \ / ___\| | _/ __ \ -// \ \_\ ( <_> | <_> ) /_/ > |_\ ___/ -// \______ /\____/ \____/\___ /|____/\___ > -// \/ /_____/ \/ - -type SocialGoogle struct { - *oauth2.Config - allowedDomains []string - apiUrl string - allowSignup bool -} - -func (s *SocialGoogle) Type() int { - return int(models.GOOGLE) -} - -func (s *SocialGoogle) IsEmailAllowed(email string) bool { - return isEmailAllowed(email, s.allowedDomains) -} - -func (s *SocialGoogle) IsSignupAllowed() bool { - return s.allowSignup -} - -func (s *SocialGoogle) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { - var data struct { - Id string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - } - var err error - - client := s.Client(oauth2.NoContext, token) - r, err := client.Get(s.apiUrl) - if err != nil { - return nil, err - } - defer r.Body.Close() - if err = json.NewDecoder(r.Body).Decode(&data); err != nil { - return nil, err - } - return &BasicUserInfo{ - Identity: data.Id, - Name: data.Name, - Email: data.Email, - }, nil } diff --git a/public/app/core/controllers/login_ctrl.js b/public/app/core/controllers/login_ctrl.js index 748b64dc5c1..3f31407f454 100644 --- a/public/app/core/controllers/login_ctrl.js +++ b/public/app/core/controllers/login_ctrl.js @@ -17,8 +17,10 @@ function (angular, coreModule, config) { $scope.googleAuthEnabled = config.googleAuthEnabled; $scope.githubAuthEnabled = config.githubAuthEnabled; - $scope.oauthEnabled = config.githubAuthEnabled || config.googleAuthEnabled; + $scope.oauthEnabled = config.githubAuthEnabled || config.googleAuthEnabled || config.genericOAuthEnabled; $scope.allowUserPassLogin = config.allowUserPassLogin; + $scope.genericOAuthEnabled = config.genericOAuthEnabled; + $scope.oauthProviderName = config.oauthProviderName; $scope.disableUserSignUp = config.disableUserSignUp; $scope.loginHint = config.loginHint; diff --git a/public/app/partials/login.html b/public/app/partials/login.html index 5bee63e6214..f4f5fb26d7e 100644 --- a/public/app/partials/login.html +++ b/public/app/partials/login.html @@ -59,6 +59,10 @@ with Github + + + with {{oauthProviderName || "OAuth 2"}} +