diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf4c40aa..66d42f681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ 1. [#1113](https://github.com/influxdata/chronograf/issues/1113): Add Slack channel per Kapacitor alert. 1. [#1095](https://github.com/influxdata/chronograf/pull/1095): Add new auth duration CLI option; add client heartbeat 1. [#1168](https://github.com/influxdata/chronograf/issue/1168): Expand support for --basepath on some load balancers + 1. [#1207](https://github.com/influxdata/chronograf/pull/1207): Add support for custom OAuth2 providers 1. [#1212](https://github.com/influxdata/chronograf/issue/1212): Add query templates and loading animation to the RawQueryEditor 1. [#1221](https://github.com/influxdata/chronograf/issue/1221): More sensical Cell and Dashboard defaults diff --git a/docs/auth.md b/docs/auth.md index fe3224390..9fabe6ae0 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -114,6 +114,53 @@ Like the other OAuth2 providers, access to Chronograf via Heroku can be restrict export HEROKU_ORGS=hill-valley-preservation-sociey,the-pinheads ``` +### Generic OAuth2 Provider +#### Creating OAuth Application using your own provider + +The generic OAuth2 provider is very similiar to the Github provider, but, +you are able to set your own authentication, token and API URLs. + +The callback URL path will be `/oauth/generic/callback`. So, if your chronograf +is hosted at `https://localhost:8888` then the full callback URL would be +`https://localhost:8888/oauth/generic/callback` + +The generic OAuth2 provider has many settings that are required. + +* `GENERIC_CLIENT_ID` : this application's client [identifier](https://tools.ietf.org/html/rfc6749#section-2.2) issued by the provider +* `GENERIC_CLIENT_SECRET` : this application's [secret](https://tools.ietf.org/html/rfc6749#section-2.3.1) issued by the provider +* `GENERIC_AUTH_URL` : OAuth 2.0 provider's authorization [endpoint](https://tools.ietf.org/html/rfc6749#section-3.1) URL +* `GENERIC_TOKEN_URL` : OAuth 2.0 provider's token endpoint [endpoint](https://tools.ietf.org/html/rfc6749#section-3.2) is used by the client to obtain an access token +* `TOKEN_SECRET` : Used to validate OAuth [state](https://tools.ietf.org/html/rfc6749#section-4.1.1) response. (see above) + +#### Optional Scopes +By default chronograf will ask for the `user:email` +[scope](https://tools.ietf.org/html/rfc6749#section-3.3) +of the client. If your +provider scopes email access under a different scope or scopes provide them as +comma separated values in the `GENERIC_SCOPES` environment variable. + +```sh +export GENERIC_SCOPES="openid,email" # Requests access to openid and email scopes +``` + +#### Optional Email domains +Also, the generic OAuth2 provider has a few optional parameters as well. + +* `GENERIC_API_URL` : URL that returns [OpenID UserInfo JWT](https://connect2id.com/products/server/docs/api/userinfo) (specifically email address) +* `GENERIC_DOMAINS` : Email domains user's email address must use. + +#### Configuring the look of the login page + +To configure the copy of the login page button text, set `GENERIC_NAME`. + +For example with + +```sh +export GENERIC_NAME="Hill Valley Preservation Society" +``` + +the button text will be `Login with Hill Valley Preservation Society`. + ### Optional: Configuring Authentication Duration By default, auth will remain valid for 30 days via a cookie stored in the browser. This duration can be changed with the environment variable `AUTH_DURATION`. For example, to change it to 1 hour, use: diff --git a/oauth2/generic.go b/oauth2/generic.go new file mode 100644 index 000000000..34f5fa1da --- /dev/null +++ b/oauth2/generic.go @@ -0,0 +1,156 @@ +package oauth2 + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/influxdata/chronograf" + "golang.org/x/oauth2" +) + +var _ Provider = &Generic{} + +// Generic provides OAuth Login and Callback server and is modeled +// after the Github OAuth2 provider. Callback will set an authentication +// cookie. This cookie's value is a JWT containing the user's primary +// email address. +type Generic struct { + PageName string // Name displayed on the login page + ClientID string + ClientSecret string + RequiredScopes []string + Domains []string // Optional email domain checking + AuthURL string + TokenURL string + APIURL string // APIURL returns OpenID Userinfo + Logger chronograf.Logger +} + +// Name is the name of the provider +func (g *Generic) Name() string { + if g.PageName == "" { + return "generic" + } + return g.PageName +} + +// ID returns the generic application client id +func (g *Generic) ID() string { + return g.ClientID +} + +// Secret returns the generic application client secret +func (g *Generic) Secret() string { + return g.ClientSecret +} + +// Scopes for generic provider required of the client. +func (g *Generic) Scopes() []string { + return g.RequiredScopes +} + +// Config is the Generic OAuth2 exchange information and endpoints +func (g *Generic) Config() *oauth2.Config { + return &oauth2.Config{ + ClientID: g.ID(), + ClientSecret: g.Secret(), + Scopes: g.Scopes(), + Endpoint: oauth2.Endpoint{ + AuthURL: g.AuthURL, + TokenURL: g.TokenURL, + }, + } +} + +// PrincipalID returns the email address of the user. +func (g *Generic) PrincipalID(provider *http.Client) (string, error) { + res := struct { + Email string `json:"email"` + }{} + + r, err := provider.Get(g.APIURL) + if err != nil { + return "", err + } + + defer r.Body.Close() + if err = json.NewDecoder(r.Body).Decode(&res); err != nil { + return "", err + } + + email := res.Email + + // If we did not receive an email address, try to lookup the email + // in a similar way as github + if email == "" { + email, err = g.getPrimaryEmail(provider) + if err != nil { + return "", err + } + } + + // If we need to restrict to a set of domains, we first get the org + // and filter. + if len(g.Domains) > 0 { + // If not in the domain deny permission + if ok := ofDomain(g.Domains, email); !ok { + msg := "Not a member of required domain" + g.Logger.Error(msg) + return "", fmt.Errorf(msg) + } + } + + return email, nil +} + +// UserEmail represents user's email address +type UserEmail struct { + Email *string `json:"email,omitempty"` + Primary *bool `json:"primary,omitempty"` + Verified *bool `json:"verified,omitempty"` +} + +// getPrimaryEmail gets the private email account for the authenticated user. +func (g *Generic) getPrimaryEmail(client *http.Client) (string, error) { + emailsEndpoint := g.APIURL + "/emails" + r, err := client.Get(emailsEndpoint) + if err != nil { + return "", err + } + defer r.Body.Close() + + emails := []*UserEmail{} + if err = json.NewDecoder(r.Body).Decode(&emails); err != nil { + return "", err + } + + email, err := g.primaryEmail(emails) + if err != nil { + g.Logger.Error("Unable to retrieve primary email ", err.Error()) + return "", err + } + return email, nil +} + +func (g *Generic) primaryEmail(emails []*UserEmail) (string, error) { + for _, m := range emails { + if m != nil && m.Primary != nil && m.Verified != nil && m.Email != nil { + return *m.Email, nil + } + } + return "", errors.New("No primary email address") +} + +// ofDomain makes sure that the email is in one of the required domains +func ofDomain(requiredDomains []string, email string) bool { + for _, domain := range requiredDomains { + emailDomain := fmt.Sprintf("@%s", domain) + if strings.HasSuffix(email, emailDomain) { + return true + } + } + return false +} diff --git a/oauth2/generic_test.go b/oauth2/generic_test.go new file mode 100644 index 000000000..a773c686a --- /dev/null +++ b/oauth2/generic_test.go @@ -0,0 +1,107 @@ +package oauth2_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + clog "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/oauth2" +) + +func TestGenericPrincipalID(t *testing.T) { + t.Parallel() + + response := struct { + Email string `json:"email"` + }{ + "martymcfly@pinheads.rok", + } + mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + rw.WriteHeader(http.StatusNotFound) + return + } + enc := json.NewEncoder(rw) + + rw.WriteHeader(http.StatusOK) + _ = enc.Encode(response) + })) + defer mockAPI.Close() + + logger := clog.New(clog.ParseLevel("debug")) + prov := oauth2.Generic{ + Logger: logger, + APIURL: mockAPI.URL, + } + tt, err := oauth2.NewTestTripper(logger, mockAPI, http.DefaultTransport) + if err != nil { + t.Fatal("Error initializing TestTripper: err:", err) + } + + tc := &http.Client{ + Transport: tt, + } + + got, err := prov.PrincipalID(tc) + if err != nil { + t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err) + } + + want := "martymcfly@pinheads.rok" + if got != want { + t.Fatal("Retrieved email was not as expected. Want:", want, "Got:", got) + } +} + +func TestGenericPrincipalIDDomain(t *testing.T) { + t.Parallel() + expectedEmail := []struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + }{ + {"martymcfly@pinheads.rok", true, false}, + } + mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + enc := json.NewEncoder(rw) + rw.WriteHeader(http.StatusOK) + _ = enc.Encode(struct{}{}) + return + } + if r.URL.Path == "/emails" { + enc := json.NewEncoder(rw) + rw.WriteHeader(http.StatusOK) + _ = enc.Encode(expectedEmail) + return + } + + rw.WriteHeader(http.StatusNotFound) + })) + defer mockAPI.Close() + + logger := clog.New(clog.ParseLevel("debug")) + prov := oauth2.Generic{ + Logger: logger, + Domains: []string{"pinheads.rok"}, + } + tt, err := oauth2.NewTestTripper(logger, mockAPI, http.DefaultTransport) + if err != nil { + t.Fatal("Error initializing TestTripper: err:", err) + } + + tc := &http.Client{ + Transport: tt, + } + + got, err := prov.PrincipalID(tc) + if err != nil { + t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err) + } + want := "martymcfly@pinheads.rok" + if got != want { + t.Fatal("Retrieved email was not as expected. Want:", want, "Got:", got) + } +} diff --git a/server/me.go b/server/me.go index 48d66ed6b..ba44090f9 100644 --- a/server/me.go +++ b/server/me.go @@ -3,7 +3,6 @@ package server import ( "fmt" "net/http" - "net/url" "golang.org/x/net/context" @@ -26,9 +25,7 @@ func newMeResponse(usr *chronograf.User) meResponse { base := "/chronograf/v1/users" name := "me" if usr != nil { - // TODO: Change to urls.PathEscape for go 1.8 - u := &url.URL{Path: usr.Name} - name = u.String() + name = PathEscape(usr.Name) } return meResponse{ diff --git a/server/mux.go b/server/mux.go index c229f811c..12f640419 100644 --- a/server/mux.go +++ b/server/mux.go @@ -195,9 +195,10 @@ func AuthAPI(opts MuxOpts, router chronograf.Router) (http.Handler, AuthRoutes) routes := AuthRoutes{} for _, pf := range opts.ProviderFuncs { pf(func(p oauth2.Provider, m oauth2.Mux) { - loginPath := fmt.Sprintf("%s/oauth/%s/login", opts.Basepath, strings.ToLower(p.Name())) - logoutPath := fmt.Sprintf("%s/oauth/%s/logout", opts.Basepath, strings.ToLower(p.Name())) - callbackPath := fmt.Sprintf("%s/oauth/%s/callback", opts.Basepath, strings.ToLower(p.Name())) + urlName := PathEscape(strings.ToLower(p.Name())) + loginPath := fmt.Sprintf("%s/oauth/%s/login", opts.Basepath, urlName) + logoutPath := fmt.Sprintf("%s/oauth/%s/logout", opts.Basepath, urlName) + callbackPath := fmt.Sprintf("%s/oauth/%s/callback", opts.Basepath, urlName) router.Handler("GET", loginPath, m.Login()) router.Handler("GET", logoutPath, m.Logout()) router.Handler("GET", callbackPath, m.Callback()) diff --git a/server/path.go b/server/path.go new file mode 100644 index 000000000..c1293e3cc --- /dev/null +++ b/server/path.go @@ -0,0 +1,10 @@ +package server + +import "net/url" + +// PathEscape escapes the string so it can be safely placed inside a URL path segment. +// Change to url.PathEscape for go 1.8 +func PathEscape(str string) string { + u := &url.URL{Path: str} + return u.String() +} diff --git a/server/server.go b/server/server.go index db4fb0cc4..a38f8ba8f 100644 --- a/server/server.go +++ b/server/server.go @@ -67,6 +67,15 @@ type Server struct { HerokuSecret string `long:"heroku-secret" description:"Heroku Secret for OAuth 2 support" env:"HEROKU_SECRET"` HerokuOrganizations []string `long:"heroku-organization" description:"Heroku Organization Memberships a user is required to have for access to Chronograf (comma separated)" env:"HEROKU_ORGS" env-delim:","` + GenericName string `long:"generic-name" description:"Generic OAuth2 name presented on the login page" env:"GENERIC_NAME"` + GenericClientID string `long:"generic-client-id" description:"Generic OAuth2 Client ID. Can be used own OAuth2 service." env:"GENERIC_CLIENT_ID"` + GenericClientSecret string `long:"generic-client-secret" description:"Generic OAuth2 Client Secret" env:"GENERIC_CLIENT_SECRET"` + GenericScopes []string `long:"generic-scopes" description:"Scopes requested by provider of web client." default:"user:email" env:"GENERIC_SCOPES" env-delim:","` + GenericDomains []string `long:"generic-domains" description:"Email domain users' email address to have (example.com)" env:"GENERIC_DOMAINS" env-delim:","` + GenericAuthURL string `long:"generic-auth-url" description:"OAuth 2.0 provider's authorization endpoint URL" env:"GENERIC_AUTH_URL"` + GenericTokenURL string `long:"generic-token-url" description:"OAuth 2.0 provider's token endpoint URL" env:"GENERIC_TOKEN_URL"` + GenericAPIURL string `long:"generic-api-url" description:"URL that returns OpenID UserInfo compatible information." env:"GENERIC_API_URL"` + ReportingDisabled bool `short:"r" long:"reporting-disabled" description:"Disable reporting of usage stats (os,arch,version,cluster_id,uptime) once every 24hr" env:"REPORTING_DISABLED"` LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"error" default:"info" description:"Set the logging level" env:"LOG_LEVEL"` Basepath string `short:"p" long:"basepath" description:"A URL path prefix under which all chronograf routes will be mounted" env:"BASE_PATH"` @@ -100,6 +109,13 @@ func (s *Server) UseHeroku() bool { return s.TokenSecret != "" && s.HerokuClientID != "" && s.HerokuSecret != "" } +// UseGenericOAuth2 validates the CLI parameters to enable generic oauth support +func (s *Server) UseGenericOAuth2() bool { + return s.TokenSecret != "" && s.GenericClientID != "" && + s.GenericClientSecret != "" && s.GenericAuthURL != "" && + s.GenericTokenURL != "" +} + func (s *Server) githubOAuth(logger chronograf.Logger, auth oauth2.Authenticator) (oauth2.Provider, oauth2.Mux, func() bool) { gh := oauth2.Github{ ClientID: s.GithubClientID, @@ -138,6 +154,23 @@ func (s *Server) herokuOAuth(logger chronograf.Logger, auth oauth2.Authenticator return &heroku, hMux, s.UseHeroku } +func (s *Server) genericOAuth(logger chronograf.Logger, auth oauth2.Authenticator) (oauth2.Provider, oauth2.Mux, func() bool) { + gen := oauth2.Generic{ + PageName: s.GenericName, + ClientID: s.GenericClientID, + ClientSecret: s.GenericClientSecret, + RequiredScopes: s.GenericScopes, + Domains: s.GenericDomains, + AuthURL: s.GenericAuthURL, + TokenURL: s.GenericTokenURL, + APIURL: s.GenericAPIURL, + Logger: logger, + } + jwt := oauth2.NewJWT(s.TokenSecret) + genMux := oauth2.NewAuthMux(&gen, auth, jwt, logger) + return &gen, genMux, s.UseGenericOAuth2 +} + // BuildInfo is sent to the usage client to track versions and commits type BuildInfo struct { Version string @@ -145,10 +178,7 @@ type BuildInfo struct { } func (s *Server) useAuth() bool { - gh := s.TokenSecret != "" && s.GithubClientID != "" && s.GithubClientSecret != "" - google := s.TokenSecret != "" && s.GoogleClientID != "" && s.GoogleClientSecret != "" && s.PublicURL != "" - heroku := s.TokenSecret != "" && s.HerokuClientID != "" && s.HerokuSecret != "" - return gh || google || heroku + return s.UseGithub() || s.UseGoogle() || s.UseHeroku() || s.UseGenericOAuth2() } func (s *Server) useTLS() bool { @@ -213,6 +243,7 @@ func (s *Server) Serve(ctx context.Context) error { providerFuncs = append(providerFuncs, provide(s.githubOAuth(logger, auth))) providerFuncs = append(providerFuncs, provide(s.googleOAuth(logger, auth))) providerFuncs = append(providerFuncs, provide(s.herokuOAuth(logger, auth))) + providerFuncs = append(providerFuncs, provide(s.genericOAuth(logger, auth))) s.handler = NewMux(MuxOpts{ Develop: s.Develop,