From 6b16fcea528b0f831ff3f44260e5f0679094caf9 Mon Sep 17 00:00:00 2001 From: Dan Cech Date: Tue, 11 Oct 2016 02:51:44 -0400 Subject: [PATCH] Oauth2 Updates (#6226) * break out go and js build commands * support oauth providers that return errors via redirect * remove extra call to get grafana.net org membership * removed GitHub specifics from generic OAuth * readded ability to name generic source * revert to a backward-compatible state, refactor and clean up * streamline oauth user creation, make generic oauth support more generic --- Makefile | 18 +++++-- pkg/api/login_oauth.go | 65 ++++++++++++++++++++--- pkg/setting/setting_oauth.go | 3 ++ pkg/social/generic_oauth.go | 40 +++++++++----- pkg/social/github_oauth.go | 10 ++-- pkg/social/google_oauth.go | 6 +-- pkg/social/grafananet_oauth.go | 54 +++++-------------- pkg/social/social.go | 32 ++++++----- public/app/core/controllers/login_ctrl.js | 1 + 9 files changed, 139 insertions(+), 90 deletions(-) diff --git a/Makefile b/Makefile index e335993b8b0..40597a33f79 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,28 @@ all: deps build -deps: +deps-go: go run build.go setup + +deps-js: npm install -build: +deps: deps-go deps-js + +build-go: go run build.go build + +build-js: npm run build -test: +build: build-go build-js + +test-go: go test -v ./pkg/... + +test-js: npm test +test: test-go test-js + run: ./bin/grafana-server diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 3f1a0cadaa9..a8b32e93d3c 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -1,11 +1,17 @@ package api import ( + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/base64" "errors" "fmt" - "crypto/rand" - "encoding/base64" + "io/ioutil" + "log" + "net/http" + "golang.org/x/net/context" "golang.org/x/oauth2" "github.com/grafana/grafana/pkg/bus" @@ -17,9 +23,9 @@ import ( ) func GenStateString() string { - rnd := make([]byte, 32) - rand.Read(rnd) - return base64.StdEncoding.EncodeToString(rnd) + rnd := make([]byte, 32) + rand.Read(rnd) + return base64.StdEncoding.EncodeToString(rnd) } func OAuthLogin(ctx *middleware.Context) { @@ -35,6 +41,14 @@ func OAuthLogin(ctx *middleware.Context) { return } + error := ctx.Query("error") + if error != "" { + errorDesc := ctx.Query("error_description") + ctx.Logger.Info("OAuthLogin Failed", "error", error, "errorDesc", errorDesc) + ctx.Redirect(setting.AppSubUrl + "/login?failCode=1003") + return + } + code := ctx.Query("code") if code == "" { state := GenStateString() @@ -52,7 +66,38 @@ func OAuthLogin(ctx *middleware.Context) { } // handle call back - token, err := connect.Exchange(oauth2.NoContext, code) + + // initialize oauth2 context + oauthCtx := oauth2.NoContext + if setting.OAuthService.OAuthInfos[name].TlsClientCert != "" { + cert, err := tls.LoadX509KeyPair(setting.OAuthService.OAuthInfos[name].TlsClientCert, setting.OAuthService.OAuthInfos[name].TlsClientKey) + if err != nil { + log.Fatal(err) + } + + // Load CA cert + caCert, err := ioutil.ReadFile(setting.OAuthService.OAuthInfos[name].TlsClientCa) + if err != nil { + log.Fatal(err) + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + + tr := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + Certificates: []tls.Certificate{cert}, + RootCAs: caCertPool, + }, + } + sslcli := &http.Client{Transport: tr} + + oauthCtx = context.TODO() + oauthCtx = context.WithValue(oauthCtx, oauth2.HTTPClient, sslcli) + } + + // get token from provider + token, err := connect.Exchange(oauthCtx, code) if err != nil { ctx.Handle(500, "login.OAuthLogin(NewTransportWithCode)", err) return @@ -60,7 +105,11 @@ func OAuthLogin(ctx *middleware.Context) { ctx.Logger.Debug("OAuthLogin Got token") - userInfo, err := connect.UserInfo(token) + // set up oauth2 client + client := connect.Client(oauthCtx, token) + + // get user info + userInfo, err := connect.UserInfo(client) if err != nil { if err == social.ErrMissingTeamMembership { ctx.Redirect(setting.AppSubUrl + "/login?failCode=1000") @@ -100,7 +149,7 @@ func OAuthLogin(ctx *middleware.Context) { return } cmd := m.CreateUserCommand{ - Login: userInfo.Email, + Login: userInfo.Login, Email: userInfo.Email, Name: userInfo.Name, Company: userInfo.Company, diff --git a/pkg/setting/setting_oauth.go b/pkg/setting/setting_oauth.go index 8d51343e635..e652310a621 100644 --- a/pkg/setting/setting_oauth.go +++ b/pkg/setting/setting_oauth.go @@ -9,6 +9,9 @@ type OAuthInfo struct { ApiUrl string AllowSignup bool Name string + TlsClientCert string + TlsClientKey string + TlsClientCa string } type OAuther struct { diff --git a/pkg/social/generic_oauth.go b/pkg/social/generic_oauth.go index f016c87e201..4b7ec065bc3 100644 --- a/pkg/social/generic_oauth.go +++ b/pkg/social/generic_oauth.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/http" - "strconv" "github.com/grafana/grafana/pkg/models" @@ -160,15 +159,16 @@ func (s *GenericOAuth) FetchOrganizations(client *http.Client) ([]string, error) return logins, nil } -func (s *GenericOAuth) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { +func (s *GenericOAuth) UserInfo(client *http.Client) (*BasicUserInfo, error) { var data struct { - Id int `json:"id"` - Name string `json:"login"` - Email string `json:"email"` + Name string `json:"name"` + Login string `json:"login"` + Username string `json:"username"` + Email string `json:"email"` + Attributes map[string][]string `json:"attributes"` } var err error - client := s.Client(oauth2.NoContext, token) r, err := client.Get(s.apiUrl) if err != nil { return nil, err @@ -181,11 +181,30 @@ func (s *GenericOAuth) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { } userInfo := &BasicUserInfo{ - Identity: strconv.Itoa(data.Id), Name: data.Name, + Login: data.Login, Email: data.Email, } + if (userInfo.Email == "" && data.Attributes["email:primary"] != nil) { + userInfo.Email = data.Attributes["email:primary"][0] + } + + if userInfo.Email == "" { + userInfo.Email, err = s.FetchPrivateEmail(client) + if err != nil { + return nil, err + } + } + + if (userInfo.Login == "" && data.Username != "") { + userInfo.Login = data.Username + } + + if (userInfo.Login == "") { + userInfo.Login = data.Email + } + if !s.IsTeamMember(client) { return nil, errors.New("User not a member of one of the required teams") } @@ -194,12 +213,5 @@ func (s *GenericOAuth) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { 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 index 40c8f2a2f7c..f3a9d4ece0f 100644 --- a/pkg/social/github_oauth.go +++ b/pkg/social/github_oauth.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/http" - "strconv" "github.com/grafana/grafana/pkg/models" @@ -168,15 +167,14 @@ func (s *SocialGithub) FetchOrganizations(client *http.Client) ([]string, error) return logins, nil } -func (s *SocialGithub) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { +func (s *SocialGithub) UserInfo(client *http.Client) (*BasicUserInfo, error) { var data struct { Id int `json:"id"` - Name string `json:"login"` + Login 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 @@ -189,8 +187,8 @@ func (s *SocialGithub) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { } userInfo := &BasicUserInfo{ - Identity: strconv.Itoa(data.Id), - Name: data.Name, + Name: data.Login, + Login: data.Login, Email: data.Email, } diff --git a/pkg/social/google_oauth.go b/pkg/social/google_oauth.go index 7f0fdcc250a..a2687d58f81 100644 --- a/pkg/social/google_oauth.go +++ b/pkg/social/google_oauth.go @@ -2,6 +2,7 @@ package social import ( "encoding/json" + "net/http" "github.com/grafana/grafana/pkg/models" @@ -27,15 +28,13 @@ func (s *SocialGoogle) IsSignupAllowed() bool { return s.allowSignup } -func (s *SocialGoogle) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { +func (s *SocialGoogle) UserInfo(client *http.Client) (*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 @@ -45,7 +44,6 @@ func (s *SocialGoogle) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { return nil, err } return &BasicUserInfo{ - Identity: data.Id, Name: data.Name, Email: data.Email, }, nil diff --git a/pkg/social/grafananet_oauth.go b/pkg/social/grafananet_oauth.go index 80c1aaedb45..119b7a31cfc 100644 --- a/pkg/social/grafananet_oauth.go +++ b/pkg/social/grafananet_oauth.go @@ -2,9 +2,7 @@ package social import ( "encoding/json" - "fmt" "net/http" - "strconv" "github.com/grafana/grafana/pkg/models" @@ -18,6 +16,10 @@ type SocialGrafanaNet struct { allowSignup bool } +type OrgRecord struct { + Login string `json:"login"` +} + func (s *SocialGrafanaNet) Type() int { return int(models.GRAFANANET) } @@ -30,19 +32,14 @@ func (s *SocialGrafanaNet) IsSignupAllowed() bool { return s.allowSignup } -func (s *SocialGrafanaNet) IsOrganizationMember(client *http.Client) bool { +func (s *SocialGrafanaNet) IsOrganizationMember(organizations []OrgRecord) 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 { + if organization.Login == allowedOrganization { return true } } @@ -51,43 +48,16 @@ func (s *SocialGrafanaNet) IsOrganizationMember(client *http.Client) bool { return false } -func (s *SocialGrafanaNet) FetchOrganizations(client *http.Client) ([]string, error) { - type Record struct { - Login string `json:"login"` - } - - url := fmt.Sprintf(s.url + "/api/oauth2/user/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 *SocialGrafanaNet) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) { +func (s *SocialGrafanaNet) UserInfo(client *http.Client) (*BasicUserInfo, error) { var data struct { - Id int `json:"id"` - Name string `json:"login"` + Name string `json:"name"` + Login string `json:"username"` Email string `json:"email"` Role string `json:"role"` + Orgs []OrgRecord `json:"orgs"` } var err error - client := s.Client(oauth2.NoContext, token) r, err := client.Get(s.url + "/api/oauth2/user") if err != nil { return nil, err @@ -100,13 +70,13 @@ func (s *SocialGrafanaNet) UserInfo(token *oauth2.Token) (*BasicUserInfo, error) } userInfo := &BasicUserInfo{ - Identity: strconv.Itoa(data.Id), Name: data.Name, + Login: data.Login, Email: data.Email, Role: data.Role, } - if !s.IsOrganizationMember(client) { + if !s.IsOrganizationMember(data.Orgs) { return nil, ErrMissingOrganizationMembership } diff --git a/pkg/social/social.go b/pkg/social/social.go index 4dbc70d71a9..f70bdd70843 100644 --- a/pkg/social/social.go +++ b/pkg/social/social.go @@ -1,16 +1,16 @@ package social import ( + "net/http" "strings" - "github.com/grafana/grafana/pkg/setting" "golang.org/x/net/context" - "golang.org/x/oauth2" + + "github.com/grafana/grafana/pkg/setting" ) type BasicUserInfo struct { - Identity string Name string Email string Login string @@ -20,12 +20,13 @@ type BasicUserInfo struct { type SocialConnector interface { Type() int - UserInfo(token *oauth2.Token) (*BasicUserInfo, error) + UserInfo(client *http.Client) (*BasicUserInfo, error) IsEmailAllowed(email string) bool IsSignupAllowed() bool AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string Exchange(ctx context.Context, code string) (*oauth2.Token, error) + Client(ctx context.Context, t *oauth2.Token) *http.Client } var ( @@ -52,6 +53,9 @@ func NewOAuthService() { AllowedDomains: sec.Key("allowed_domains").Strings(" "), AllowSignup: sec.Key("allow_sign_up").MustBool(), Name: sec.Key("name").MustString(name), + TlsClientCert: sec.Key("tls_client_cert").String(), + TlsClientKey: sec.Key("tls_client_key").String(), + TlsClientCa: sec.Key("tls_client_ca").String(), } if !info.Enabled { @@ -59,6 +63,7 @@ func NewOAuthService() { } setting.OAuthService.OAuthInfos[name] = info + config := oauth2.Config{ ClientID: info.ClientId, ClientSecret: info.ClientSecret, @@ -85,9 +90,10 @@ func NewOAuthService() { // Google. if name == "google" { SocialMap["google"] = &SocialGoogle{ - Config: &config, allowedDomains: info.AllowedDomains, - apiUrl: info.ApiUrl, - allowSignup: info.AllowSignup, + Config: &config, + allowedDomains: info.AllowedDomains, + apiUrl: info.ApiUrl, + allowSignup: info.AllowSignup, } } @@ -104,15 +110,15 @@ func NewOAuthService() { } if name == "grafananet" { - config := oauth2.Config{ + config = oauth2.Config{ ClientID: info.ClientId, ClientSecret: info.ClientSecret, - Endpoint: oauth2.Endpoint{ - AuthURL: setting.GrafanaNetUrl + "/oauth2/authorize", - TokenURL: setting.GrafanaNetUrl + "/api/oauth2/token", + Endpoint: oauth2.Endpoint{ + AuthURL: setting.GrafanaNetUrl + "/oauth2/authorize", + TokenURL: setting.GrafanaNetUrl + "/api/oauth2/token", }, - RedirectURL: strings.TrimSuffix(setting.AppUrl, "/") + SocialBaseUrl + name, - Scopes: info.Scopes, + RedirectURL: strings.TrimSuffix(setting.AppUrl, "/") + SocialBaseUrl + name, + Scopes: info.Scopes, } SocialMap["grafananet"] = &SocialGrafanaNet{ diff --git a/public/app/core/controllers/login_ctrl.js b/public/app/core/controllers/login_ctrl.js index 4b03dddf973..fa3af3d10f0 100644 --- a/public/app/core/controllers/login_ctrl.js +++ b/public/app/core/controllers/login_ctrl.js @@ -11,6 +11,7 @@ function (angular, _, coreModule, config) { "1000": "Required team membership not fulfilled", "1001": "Required organization membership not fulfilled", "1002": "Required email domain not fulfilled", + "1003": "Login provider denied login request", }; coreModule.default.controller('LoginCtrl', function($scope, backendSrv, contextSrv, $location) {