Merge branch 'master' of github.com:influxdata/chronograf

pull/10616/head
Hunter Trujillo 2017-04-07 15:58:12 -06:00
commit 7c2afb75f8
8 changed files with 361 additions and 11 deletions

View File

@ -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

View File

@ -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:

156
oauth2/generic.go Normal file
View File

@ -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
}

107
oauth2/generic_test.go Normal file
View File

@ -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)
}
}

View File

@ -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{

View File

@ -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())

10
server/path.go Normal file
View File

@ -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()
}

View File

@ -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,