Merge branch 'master' of github.com:influxdata/chronograf
commit
7c2afb75f8
|
@ -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
|
||||
|
||||
|
|
47
docs/auth.md
47
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:
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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{
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue