diff --git a/CHANGELOG.md b/CHANGELOG.md index 190241c37..fe5557ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,14 +17,24 @@ 1. [#1179](https://github.com/influxdata/chronograf/pull/1179): Admin Databases Page will render a database without retention policies 1. [#1128](https://github.com/influxdata/chronograf/pull/1128): No more ghost dashboards πŸ‘» 1. [#1189](https://github.com/influxdata/chronograf/pull/1189): Clicking inside the graph header edit box will no longer blur the field. Use the Escape key for that behavior instead. + 1. [#1193](https://github.com/influxdata/chronograf/issues/1193): Fix no quoting of raw InfluxQL fields with function names + 1. [#1195](https://github.com/influxdata/chronograf/issues/1195): Chronograf was not redirecting with authentiation for Influx Enterprise Meta service + 1. [#1095](https://github.com/influxdata/chronograf/pull/1095): Make logout button display again + 1. [#1209](https://github.com/influxdata/chronograf/pull/1209): HipChat Kapacitor config now uses only the subdomain instead of asking for the entire HipChat URL. + 1. [#1219](https://github.com/influxdata/chronograf/pull/1219): Update query for default cell in new dashboard + 1. [#1206](https://github.com/influxdata/chronograf/issues/1206): Chronograf now proxies to kapacitors behind proxy using vhost correctly. ### Features 1. [#1112](https://github.com/influxdata/chronograf/pull/1112): Add ability to delete a dashboard 1. [#1120](https://github.com/influxdata/chronograf/pull/1120): Allow users to update user passwords. 1. [#1129](https://github.com/influxdata/chronograf/pull/1129): Allow InfluxDB and Kapacitor configuration via ENV vars or CLI options 1. [#1130](https://github.com/influxdata/chronograf/pull/1130): Add loading spinner to Alert History page. + 1. [#1168](https://github.com/influxdata/chronograf/issues/1168): Expand support for --basepath on some load balancers + 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. [#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 ### UI Improvements 1. [#1101](https://github.com/influxdata/chronograf/pull/1101): Compress InfluxQL responses with gzip @@ -35,6 +45,7 @@ 1. [#1124](https://github.com/influxdata/chronograf/pull/1124): Polished dashboard cell drag interaction, use Hover-To-Reveal UI pattern in all tables, Source Indicator & Graph Tips are no longer misleading, and aesthetic improvements to the DB Management page 1. [#1187](https://github.com/influxdata/chronograf/pull/1187): Replace Kill Query confirmation modal with ConfirmButtons 1. [#1185](https://github.com/influxdata/chronograf/pull/1185): Alphabetically sort Admin Database Page + 1. [#1199](https://github.com/influxdata/chronograf/pull/1199): Move Rename Cell functionality to ContextMenu dropdown ## v1.2.0-beta7 [2017-03-28] ### Bug Fixes diff --git a/Makefile b/Makefile index 478358031..e652c3844 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,20 @@ dev: dep dev-assets ${BINARY} ${BINARY}: $(SOURCES) .bindata .jsdep .godep go build -o ${BINARY} ${LDFLAGS} ./cmd/chronograf/main.go +define CHRONOGIRAFFE + ._ o o + \_`-)|_ + ,"" _\_ + ," ## | 0 0. + ," ## ,-\__ `. + ," / `--._;) + ," ## / +," ## / +endef +export CHRONOGIRAFFE +chronogiraffe: ${BINARY} + @echo "$$CHRONOGIRAFFE" + docker-${BINARY}: $(SOURCES) CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o ${BINARY} ${LDFLAGS} \ ./cmd/chronograf/main.go @@ -93,7 +107,7 @@ jstest: run: ${BINARY} ./chronograf -run-dev: ${BINARY} +run-dev: chronogiraffe ./chronograf -d --log-level=debug clean: diff --git a/docs/auth.md b/docs/auth.md index f4a795d36..fe3224390 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -2,23 +2,33 @@ OAuth 2.0 Style Authentication +### TL;DR +#### Github + +```sh +export AUTH_DURATION=1h # force login every hour +export TOKEN_SECRET=supersupersecret # Signing secret +export GH_CLIENT_ID=b339dd4fddd95abec9aa # Github client id +export GH_CLIENT_SECRET=260041897d3252c146ece6b46ba39bc1e54416dc # Github client secret +export GH_ORGS=biffs-gang # Restrict to GH orgs +``` ### Configuration -To use authentication in Chronograf, both Github OAuth and JWT signature need to be configured. +To use authentication in Chronograf, both the OAuth provider and JWT signature +need to be configured. #### Configuring JWT signature Set a [JWT](https://tools.ietf.org/html/rfc7519) signature to a random string. This is needed for all OAuth2 providers that you choose to configure. *Keep this random string around!* -You'll need it each time you start a chronograf server because it is used to verify user authorization. If you are running multiple chronograf servers in an HA configuration set the `TOKEN_SECRET` on each to allow users to stay logged in. +You'll need it each time you start a chronograf server because it is used to verify user authorization. If you are running multiple chronograf servers in an HA configuration set the `TOKEN_SECRET` on each to allow users to stay logged in. If you want to log all users out every time the server restarts, change the value of `TOKEN_SECRET` to a different value on each restart. ```sh export TOKEN_SECRET=supersupersecret ``` -# Github - +### Github #### Creating Github OAuth Application To create a Github OAuth Application follow the [Register your app](https://developer.github.com/guides/basics-of-authentication/#registering-your-app) instructions. @@ -26,13 +36,13 @@ Essentially, you'll register your application [here](https://github.com/settings The `Homepage URL` should be Chronograf's full server name and port. If you are running it locally for example, make it `http://localhost:8888` -The `Authorization callback URL` must be the location of the `Homepage URL` plus `/oauth/github/callback`. For example, if `Homepage URL` was +The `Authorization callback URL` must be the location of the `Homepage URL` plus `/oauth/github/callback`. For example, if `Homepage URL` was `http://localhost:8888` then the `Authorization callback URL` should be `http://localhost:8888/oauth/github/callback`. Github will provide a `Client ID` and `Client Secret`. To register these values with chronograf set the following environment variables: -* `GH_CLIENT_ID` -* `GH_CLIENT_SECRET` +* `GH_CLIENT_ID` +* `GH_CLIENT_SECRET` For example: @@ -56,7 +66,7 @@ To support multiple organizations use a comma delimted list like so: export GH_ORGS=hill-valley-preservation-sociey,the-pinheads ``` -# Google +### Google #### Creating Google OAuth Application @@ -82,7 +92,7 @@ Similar to Github's organization restriction, Google authentication can be restr export GOOGLE_DOMAINS=biffspleasurepalance.com,savetheclocktower.com ``` -# Heroku +### Heroku #### Creating Heroku Application @@ -103,3 +113,19 @@ Like the other OAuth2 providers, access to Chronograf via Heroku can be restrict ```sh export HEROKU_ORGS=hill-valley-preservation-sociey,the-pinheads ``` + +### 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: + +```sh +export AUTH_DURATION=1h +``` + +The duration uses the golang [time duration format](https://golang.org/pkg/time/#ParseDuration), so the largest time unit is `h` (hours). So to change it to 45 days, use: + +```sh +export AUTH_DURATION=1080h +``` + +Additionally, for greater security, if you want to require re-authentication every time the browser is closed, set `AUTH_DURATION` to `0`. This will make the cookie transient (aka "in-memory"). diff --git a/enterprise/meta.go b/enterprise/meta.go index cd08730b7..28518aa1d 100644 --- a/enterprise/meta.go +++ b/enterprise/meta.go @@ -384,7 +384,12 @@ func (d *defaultClient) Do(URL *url.URL, path, method string, params map[string] req.Header.Set("Content-Type", "application/json") } - res, err := http.DefaultClient.Do(req) + // Meta servers will redirect (307) to leader. We need + // special handling to preserve authentication headers. + client := &http.Client{ + CheckRedirect: AuthedCheckRedirect, + } + res, err := client.Do(req) if err != nil { return nil, err } @@ -404,6 +409,21 @@ func (d *defaultClient) Do(URL *url.URL, path, method string, params map[string] } +// AuthedCheckRedirect tries to follow the Influx Enterprise pattern of +// redirecting to the leader but preserving authentication headers. +func AuthedCheckRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("too many redirects") + } else if len(via) == 0 { + return nil + } + preserve := "Authorization" + if auth, ok := via[0].Header[preserve]; ok { + req.Header[preserve] = auth + } + return nil +} + // Do is a cancelable function to interface with Influx Enterprise's Meta API func (m *MetaClient) Do(ctx context.Context, method, path string, params map[string]string, body io.Reader) (*http.Response, error) { type result struct { diff --git a/enterprise/meta_test.go b/enterprise/meta_test.go index 96150d054..f8a1c3ffc 100644 --- a/enterprise/meta_test.go +++ b/enterprise/meta_test.go @@ -7,6 +7,7 @@ import ( "io" "io/ioutil" "net/http" + "net/http/httptest" "net/url" "reflect" "testing" @@ -1396,3 +1397,59 @@ func (c *MockClient) Do(URL *url.URL, path, method string, params map[string]str Body: ioutil.NopCloser(bytes.NewReader(c.Body)), }, nil } + +func Test_AuthedCheckRedirect_Do(t *testing.T) { + var ts2URL string + ts1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := http.Header{ + "Referer": []string{ts2URL}, + "Accept-Encoding": []string{"gzip"}, + "Authorization": []string{"hunter2"}, + } + for k, v := range want { + if !reflect.DeepEqual(r.Header[k], v) { + t.Errorf("Request.Header = %#v; want %#v", r.Header[k], v) + } + } + if t.Failed() { + w.Header().Set("Result", "got errors") + } else { + w.Header().Set("Result", "ok") + } + })) + defer ts1.Close() + + ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, ts1.URL, http.StatusFound) + })) + defer ts2.Close() + ts2URL = ts2.URL + + tr := &http.Transport{} + defer tr.CloseIdleConnections() + + c := &http.Client{ + Transport: tr, + CheckRedirect: AuthedCheckRedirect, + } + + req, _ := http.NewRequest("GET", ts2.URL, nil) + req.Header.Add("Cookie", "foo=bar") + req.Header.Add("Authorization", "hunter2") + req.Header.Add("Howdy", "doody") + req.Header.Set("User-Agent", "Darth Vader, an extraterrestrial from the Planet Vulcan") + + res, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + + defer res.Body.Close() + if res.StatusCode != 200 { + t.Fatal(res.Status) + } + + if got := res.Header.Get("Result"); got != "ok" { + t.Errorf("result = %q; want ok", got) + } +} diff --git a/influx/query.go b/influx/query.go index 260189824..c3340bb9c 100644 --- a/influx/query.go +++ b/influx/query.go @@ -15,7 +15,7 @@ func Convert(influxQL string) (chronograf.QueryConfig, error) { } raw := chronograf.QueryConfig{ - RawText: query.String(), + RawText: influxQL, Fields: []chronograf.Field{}, GroupBy: chronograf.GroupBy{ Tags: []string{}, diff --git a/influx/query_test.go b/influx/query_test.go new file mode 100644 index 000000000..b930cb4fa --- /dev/null +++ b/influx/query_test.go @@ -0,0 +1,42 @@ +package influx + +import ( + "reflect" + "testing" + + "github.com/influxdata/chronograf" +) + +func TestConvert(t *testing.T) { + tests := []struct { + name string + influxQL string + want chronograf.QueryConfig + wantErr bool + }{ + { + name: "Test named count field", + influxQL: `SELECT moving_average(mean("count"),14) FROM "usage_computed"."autogen".unique_clusters_by_day WHERE time > now() - 90d AND product = 'influxdb' group by time(1d)`, + want: chronograf.QueryConfig{ + RawText: `SELECT moving_average(mean("count"),14) FROM "usage_computed"."autogen".unique_clusters_by_day WHERE time > now() - 90d AND product = 'influxdb' group by time(1d)`, + Fields: []chronograf.Field{}, + Tags: map[string][]string{}, + GroupBy: chronograf.GroupBy{ + Tags: []string{}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Convert(tt.influxQL) + if (err != nil) != tt.wantErr { + t.Errorf("Convert() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Convert() = %#v, want %#v", got, tt.want) + } + }) + } +} diff --git a/oauth2/auth.go b/oauth2/auth.go deleted file mode 100644 index fc132eab2..000000000 --- a/oauth2/auth.go +++ /dev/null @@ -1,77 +0,0 @@ -package oauth2 - -import ( - "context" - "net/http" - "strings" - - "github.com/influxdata/chronograf" -) - -// CookieExtractor extracts the token from the value of the Name cookie. -type CookieExtractor struct { - Name string -} - -// Extract returns the value of cookie Name -func (c *CookieExtractor) Extract(r *http.Request) (string, error) { - cookie, err := r.Cookie(c.Name) - if err != nil { - return "", ErrAuthentication - } - return cookie.Value, nil -} - -// BearerExtractor extracts the token from Authorization: Bearer header. -type BearerExtractor struct{} - -// Extract returns the string following Authorization: Bearer -func (b *BearerExtractor) Extract(r *http.Request) (string, error) { - s := r.Header.Get("Authorization") - if s == "" { - return "", ErrAuthentication - } - - // Check for Bearer token. - strs := strings.Split(s, " ") - - if len(strs) != 2 || strs[0] != "Bearer" { - return "", ErrAuthentication - } - return strs[1], nil -} - -// AuthorizedToken extracts the token and validates; if valid the next handler -// will be run. The principal will be sent to the next handler via the request's -// Context. It is up to the next handler to determine if the principal has access. -// On failure, will return http.StatusUnauthorized. -func AuthorizedToken(auth Authenticator, te TokenExtractor, logger chronograf.Logger, next http.Handler) http.HandlerFunc { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log := logger. - WithField("component", "auth"). - WithField("remote_addr", r.RemoteAddr). - WithField("method", r.Method). - WithField("url", r.URL) - - token, err := te.Extract(r) - if err != nil { - // Happens when Provider okays authentication, but Token is bad - log.Info("Unauthenticated user") - w.WriteHeader(http.StatusUnauthorized) - return - } - // We do not check the validity of the principal. Those - // served further down the chain should do so. - principal, err := auth.Authenticate(r.Context(), token) - if err != nil { - log.Error("Invalid token") - w.WriteHeader(http.StatusUnauthorized) - return - } - - // Send the principal to the next handler - ctx := context.WithValue(r.Context(), PrincipalKey, principal) - next.ServeHTTP(w, r.WithContext(ctx)) - return - }) -} diff --git a/oauth2/auth_test.go b/oauth2/auth_test.go deleted file mode 100644 index 40d2ec36f..000000000 --- a/oauth2/auth_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package oauth2_test - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "testing" - "time" - - clog "github.com/influxdata/chronograf/log" - "github.com/influxdata/chronograf/oauth2" -) - -func TestCookieExtractor(t *testing.T) { - var test = []struct { - Desc string - Name string - Value string - Lookup string - Expected string - Err error - }{ - { - Desc: "No cookie of this name", - Name: "Auth", - Value: "reallyimportant", - Lookup: "Doesntexist", - Expected: "", - Err: oauth2.ErrAuthentication, - }, - { - Desc: "Cookie token extracted", - Name: "Auth", - Value: "reallyimportant", - Lookup: "Auth", - Expected: "reallyimportant", - Err: nil, - }, - } - for _, test := range test { - req, _ := http.NewRequest("", "http://howdy.com", nil) - req.AddCookie(&http.Cookie{ - Name: test.Name, - Value: test.Value, - }) - - var e oauth2.TokenExtractor = &oauth2.CookieExtractor{ - Name: test.Lookup, - } - actual, err := e.Extract(req) - if err != test.Err { - t.Errorf("Cookie extract error; expected %v actual %v", test.Err, err) - } - - if actual != test.Expected { - t.Errorf("Token extract error; expected %v actual %v", test.Expected, actual) - } - } -} - -func TestBearerExtractor(t *testing.T) { - var test = []struct { - Desc string - Header string - Value string - Lookup string - Expected string - Err error - }{ - { - Desc: "No header of this name", - Header: "Doesntexist", - Value: "reallyimportant", - Expected: "", - Err: oauth2.ErrAuthentication, - }, - { - Desc: "Auth header doesn't have Bearer", - Header: "Authorization", - Value: "Bad Value", - Expected: "", - Err: oauth2.ErrAuthentication, - }, - { - Desc: "Auth header doesn't have Bearer token", - Header: "Authorization", - Value: "Bearer", - Expected: "", - Err: oauth2.ErrAuthentication, - }, - { - Desc: "Authorization Bearer token success", - Header: "Authorization", - Value: "Bearer howdy", - Expected: "howdy", - Err: nil, - }, - } - for _, test := range test { - req, _ := http.NewRequest("", "http://howdy.com", nil) - req.Header.Add(test.Header, test.Value) - - var e oauth2.TokenExtractor = &oauth2.BearerExtractor{} - actual, err := e.Extract(req) - if err != test.Err { - t.Errorf("Bearer extract error; expected %v actual %v", test.Err, err) - } - - if actual != test.Expected { - t.Errorf("Token extract error; expected %v actual %v", test.Expected, actual) - } - } -} - -type MockExtractor struct { - Err error -} - -func (m *MockExtractor) Extract(*http.Request) (string, error) { - return "", m.Err -} - -type MockAuthenticator struct { - Principal oauth2.Principal - Err error -} - -func (m *MockAuthenticator) Authenticate(context.Context, string) (oauth2.Principal, error) { - return m.Principal, m.Err -} - -func (m *MockAuthenticator) Token(context.Context, oauth2.Principal, time.Duration) (string, error) { - return "", m.Err -} - -func TestAuthorizedToken(t *testing.T) { - var tests = []struct { - Desc string - Code int - Principal oauth2.Principal - ExtractorErr error - AuthErr error - Expected string - }{ - { - Desc: "Error in extractor", - Code: http.StatusUnauthorized, - ExtractorErr: errors.New("error"), - }, - { - Desc: "Error in extractor", - Code: http.StatusUnauthorized, - AuthErr: errors.New("error"), - }, - { - Desc: "Authorized ok", - Code: http.StatusOK, - Principal: oauth2.Principal{ - Subject: "Principal Strickland", - }, - Expected: "Principal Strickland", - }, - } - for _, test := range tests { - // next is a sentinel StatusOK and - // principal recorder. - var principal oauth2.Principal - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - principal = r.Context().Value(oauth2.PrincipalKey).(oauth2.Principal) - }) - req, _ := http.NewRequest("GET", "", nil) - w := httptest.NewRecorder() - - e := &MockExtractor{ - Err: test.ExtractorErr, - } - a := &MockAuthenticator{ - Err: test.AuthErr, - Principal: test.Principal, - } - - logger := clog.New(clog.DebugLevel) - handler := oauth2.AuthorizedToken(a, e, logger, next) - handler.ServeHTTP(w, req) - if w.Code != test.Code { - t.Errorf("Status code expected: %d actual %d", test.Code, w.Code) - } else if principal != test.Principal { - t.Errorf("Principal mismatch expected: %s actual %s", test.Principal, principal) - } - } -} diff --git a/oauth2/cookies.go b/oauth2/cookies.go new file mode 100644 index 000000000..7bc701cfb --- /dev/null +++ b/oauth2/cookies.go @@ -0,0 +1,81 @@ +package oauth2 + +import ( + "context" + "net/http" + "time" +) + +const ( + // DefaultCookieName is the name of the stored cookie + DefaultCookieName = "session" +) + +var _ Authenticator = &cookie{} + +// cookie represents the location and expiration time of new cookies. +type cookie struct { + Name string + Duration time.Duration + Now func() time.Time + Tokens Tokenizer +} + +// NewCookieJWT creates an Authenticator that uses cookies for auth +func NewCookieJWT(secret string, duration time.Duration) Authenticator { + return &cookie{ + Name: DefaultCookieName, + Duration: duration, + Now: time.Now, + Tokens: &JWT{ + Secret: secret, + Now: time.Now, + }, + } +} + +// Validate returns Principal of the Cookie if the Token is valid. +func (c *cookie) Validate(ctx context.Context, r *http.Request) (Principal, error) { + cookie, err := r.Cookie(c.Name) + if err != nil { + return Principal{}, ErrAuthentication + } + return c.Tokens.ValidPrincipal(ctx, Token(cookie.Value), c.Duration) +} + +// Authorize will create cookies containing token information. It'll create +// a token with cookie.Duration of life to be stored as the cookie's value. +func (c *cookie) Authorize(ctx context.Context, w http.ResponseWriter, p Principal) error { + token, err := c.Tokens.Create(ctx, p, c.Duration) + if err != nil { + return err + } + // Cookie has a Token baked into it + cookie := http.Cookie{ + Name: DefaultCookieName, + Value: string(token), + HttpOnly: true, + Path: "/", + } + + // Only set a cookie to be persistent (endure beyond the browser session) + // if auth duration is greater than zero + if c.Duration > 0 { + cookie.Expires = c.Now().UTC().Add(c.Duration) + } + + http.SetCookie(w, &cookie) + return nil +} + +// Expire returns a cookie that will expire an existing cookie +func (c *cookie) Expire(w http.ResponseWriter) { + cookie := http.Cookie{ + Name: DefaultCookieName, + Value: "none", + HttpOnly: true, + Path: "/", + Expires: c.Now().UTC().Add(-1 * time.Hour), // to expire cookie set the time in the past + } + http.SetCookie(w, &cookie) +} diff --git a/oauth2/cookies_test.go b/oauth2/cookies_test.go new file mode 100644 index 000000000..e2303aafa --- /dev/null +++ b/oauth2/cookies_test.go @@ -0,0 +1,152 @@ +package oauth2 + +import ( + "context" + "fmt" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type MockTokenizer struct { + Principal Principal + ValidErr error + Token Token + CreateErr error +} + +func (m *MockTokenizer) ValidPrincipal(ctx context.Context, token Token, duration time.Duration) (Principal, error) { + return m.Principal, m.ValidErr +} + +func (m *MockTokenizer) Create(ctx context.Context, p Principal, t time.Duration) (Token, error) { + return m.Token, m.CreateErr +} + +func TestCookieAuthorize(t *testing.T) { + var test = []struct { + Desc string + Value string + Expected string + Err error + CreateErr error + }{ + { + Desc: "Unable to create token", + Err: ErrAuthentication, + CreateErr: ErrAuthentication, + }, + { + Desc: "Cookie token extracted", + Value: "reallyimportant", + Expected: "reallyimportant", + Err: nil, + }, + } + for _, test := range test { + cook := cookie{ + Duration: 1 * time.Second, + Now: func() time.Time { + return time.Unix(0, 0) + }, + Tokens: &MockTokenizer{ + Token: Token(test.Value), + CreateErr: test.CreateErr, + }, + } + principal := Principal{} + w := httptest.NewRecorder() + err := cook.Authorize(context.Background(), w, principal) + if err != test.Err { + t.Fatalf("Cookie extract error; expected %v actual %v", test.Err, err) + } + if test.Err != nil { + continue + } + + cookies := w.HeaderMap["Set-Cookie"] + + if len(cookies) == 0 { + t.Fatal("Expected some cookies but got zero") + } + log.Printf("%s", cookies[0]) + if !strings.Contains(cookies[0], fmt.Sprintf("%s=%s", DefaultCookieName, test.Expected)) { + t.Errorf("Token extract error; expected %v actual %v", test.Expected, principal.Subject) + } + } +} + +func TestCookieValidate(t *testing.T) { + var test = []struct { + Desc string + Name string + Value string + Lookup string + Expected string + Err error + ValidErr error + }{ + { + Desc: "No cookie of this name", + Name: "Auth", + Value: "reallyimportant", + Lookup: "Doesntexist", + Expected: "", + Err: ErrAuthentication, + }, + { + Desc: "Unable to create token", + Name: "Auth", + Lookup: "Auth", + Err: ErrAuthentication, + ValidErr: ErrAuthentication, + }, + { + Desc: "Cookie token extracted", + Name: "Auth", + Value: "reallyimportant", + Lookup: "Auth", + Expected: "reallyimportant", + Err: nil, + }, + } + for _, test := range test { + req, _ := http.NewRequest("", "http://howdy.com", nil) + req.AddCookie(&http.Cookie{ + Name: test.Name, + Value: test.Value, + }) + + cook := cookie{ + Name: test.Lookup, + Duration: 1 * time.Second, + Now: func() time.Time { + return time.Unix(0, 0) + }, + Tokens: &MockTokenizer{ + Principal: Principal{ + Subject: test.Value, + }, + ValidErr: test.ValidErr, + }, + } + principal, err := cook.Validate(context.Background(), req) + if err != test.Err { + t.Errorf("Cookie extract error; expected %v actual %v", test.Err, err) + } + + if principal.Subject != test.Expected { + t.Errorf("Token extract error; expected %v actual %v", test.Expected, principal.Subject) + } + } +} + +func TestNewCookieJWT(t *testing.T) { + auth := NewCookieJWT("secret", time.Second) + if _, ok := auth.(*cookie); !ok { + t.Errorf("NewCookieJWT() did not create cookie Authenticator") + } +} diff --git a/oauth2/doc.go b/oauth2/doc.go index 7fed8f3b6..e902659a9 100644 --- a/oauth2/doc.go +++ b/oauth2/doc.go @@ -8,21 +8,22 @@ // β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” // β”‚β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ // β”‚β”‚ <> β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -// β”‚β”‚ Authenticator β”‚ β”‚ CookieMux β”‚ β”‚ +// β”‚β”‚ Authenticator β”‚ β”‚ AuthMux β”‚ β”‚ // β”‚β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ -// β”‚β”‚Authenticate() β”‚ Auth β”‚+SuccessURL : string β”‚ β”‚ -// β”‚β”‚Token() ◀────────│+FailureURL : string │──────────┐ β”‚ -// β”‚β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–³β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚+Now : func() time.Time β”‚ β”‚ β”‚ -// β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +// β”‚β”‚Authorize() β”‚ Auth β”‚+SuccessURL : string β”‚ β”‚ +// β”‚β”‚Validate() ◀────────│+FailureURL : string │──────────┐ β”‚ +// ||Expire() | |+Now : func() time.Time | | | +// β”‚β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–³β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ | | +// β”‚ β”‚ β”‚ β”‚ | // β”‚ β”‚ β”‚ β”‚ β”‚ // β”‚ β”‚ β”‚ β”‚ β”‚ // β”‚ β”‚ Providerβ”‚ β”‚ β”‚ // β”‚ β”‚ β”Œβ”€β”€β”€β”˜ β”‚ β”‚ // β”‚β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β–½ β”‚ -// β”‚β”‚ JWT β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +// β”‚β”‚ Tokenizer β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ // β”‚β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β–Ό β”‚ <> β”‚ β”‚ -// β”‚β”‚+Secret : string β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ OAuth2Mux β”‚ β”‚ -// β”‚β”‚+Now : func() time.Timeβ”‚ β”‚ <> β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ +// β”‚β”‚Create() β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ OAuth2Mux β”‚ β”‚ +// β”‚β”‚ValidPrincipal() β”‚ β”‚ <> β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ // β”‚β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ Provider β”‚ β”‚Login() β”‚ β”‚ // β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚Logout() β”‚ β”‚ // β”‚ β”‚ID() β”‚ β”‚Callback() β”‚ β”‚ diff --git a/oauth2/github_test.go b/oauth2/github_test.go index 07f6791d6..dc9659570 100644 --- a/oauth2/github_test.go +++ b/oauth2/github_test.go @@ -36,7 +36,7 @@ func TestGithubPrincipalID(t *testing.T) { prov := oauth2.Github{ Logger: logger, } - tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport) + tt, err := oauth2.NewTestTripper(logger, mockAPI, http.DefaultTransport) if err != nil { t.Fatal("Error initializing TestTripper: err:", err) } @@ -93,7 +93,7 @@ func TestGithubPrincipalIDOrganization(t *testing.T) { Logger: logger, Orgs: []string{"Hill Valley Preservation Society"}, } - tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport) + tt, err := oauth2.NewTestTripper(logger, mockAPI, http.DefaultTransport) if err != nil { t.Fatal("Error initializing TestTripper: err:", err) } diff --git a/oauth2/google_test.go b/oauth2/google_test.go index 1ad3ea4be..07a21d574 100644 --- a/oauth2/google_test.go +++ b/oauth2/google_test.go @@ -34,7 +34,7 @@ func TestGooglePrincipalID(t *testing.T) { prov := oauth2.Google{ Logger: logger, } - tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport) + tt, err := oauth2.NewTestTripper(logger, mockAPI, http.DefaultTransport) if err != nil { t.Fatal("Error initializing TestTripper: err:", err) } @@ -63,8 +63,6 @@ func TestGooglePrincipalIDDomain(t *testing.T) { "martymcfly@example.com", "Hill Valley Preservation Society", } - //a := goauth2.Userinfoplus{} - mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { if r.URL.Path != "/oauth2/v2/userinfo" { rw.WriteHeader(http.StatusNotFound) @@ -82,7 +80,7 @@ func TestGooglePrincipalIDDomain(t *testing.T) { Logger: logger, Domains: []string{"Hill Valley Preservation Society"}, } - tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport) + tt, err := oauth2.NewTestTripper(logger, mockAPI, http.DefaultTransport) if err != nil { t.Fatal("Error initializing TestTripper: err:", err) } diff --git a/oauth2/heroku_test.go b/oauth2/heroku_test.go index fc9619403..c4ec9734a 100644 --- a/oauth2/heroku_test.go +++ b/oauth2/heroku_test.go @@ -35,7 +35,7 @@ func Test_Heroku_PrincipalID_ExtractsEmailAddress(t *testing.T) { prov := oauth2.Heroku{ Logger: logger, } - tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport) + tt, err := oauth2.NewTestTripper(logger, mockAPI, http.DefaultTransport) if err != nil { t.Fatal("Error initializing TestTripper: err:", err) } @@ -86,7 +86,7 @@ func Test_Heroku_PrincipalID_RestrictsByOrganization(t *testing.T) { Organizations: []string{"enchantment-under-the-sea-dance-committee"}, } - tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport) + tt, err := oauth2.NewTestTripper(logger, mockAPI, http.DefaultTransport) if err != nil { t.Fatal("Error initializing TestTripper: err:", err) } diff --git a/oauth2/jwt.go b/oauth2/jwt.go index fb54b62f9..e925c3c4c 100644 --- a/oauth2/jwt.go +++ b/oauth2/jwt.go @@ -8,8 +8,8 @@ import ( gojwt "github.com/dgrijalva/jwt-go" ) -// Test if JWT implements Authenticator -var _ Authenticator = &JWT{} +// Ensure JWT conforms to the Tokenizer interface +var _ Tokenizer = &JWT{} // JWT represents a javascript web token that can be validated or marshaled into string. type JWT struct { @@ -18,8 +18,8 @@ type JWT struct { } // NewJWT creates a new JWT using time.Now; secret is used for signing and validating. -func NewJWT(secret string) JWT { - return JWT{ +func NewJWT(secret string) *JWT { + return &JWT{ Secret: secret, Now: time.Now, } @@ -28,7 +28,7 @@ func NewJWT(secret string) JWT { // Ensure Claims implements the jwt.Claims interface var _ gojwt.Claims = &Claims{} -// Claims extends jwt.StandardClaims Valid to make sure claims has a subject. +// Claims extends jwt.StandardClaims' Valid to make sure claims has a subject. type Claims struct { gojwt.StandardClaims } @@ -40,11 +40,51 @@ func (c *Claims) Valid() error { } else if c.StandardClaims.Subject == "" { return fmt.Errorf("claim has no subject") } + return nil } -// Authenticate checks if the jwtToken is signed correctly and validates with Claims. -func (j *JWT) Authenticate(ctx context.Context, jwtToken string) (Principal, error) { +// EverlastingClaims extends jwt.StandardClaims' Valid to make sure claims has +// a subject, and it ignores the expiration +type EverlastingClaims struct { + gojwt.StandardClaims + Now func() time.Time +} + +// Valid time based claims "iat, nbf". +// There is no accounting for clock skew. +// As well, if any of the above claims are not in the token, it will still +// be considered a valid claim. +func (c *EverlastingClaims) Valid() error { + vErr := new(gojwt.ValidationError) + now := c.Now().Unix() + + // The claims below are optional, by default, so if they are set to the + // default value in Go, let's not fail the verification for them. + + if c.VerifyIssuedAt(now, false) == false { + vErr.Inner = fmt.Errorf("Token used before issued") + vErr.Errors |= gojwt.ValidationErrorIssuedAt + } + + if c.VerifyNotBefore(now, false) == false { + vErr.Inner = fmt.Errorf("token is not valid yet") + vErr.Errors |= gojwt.ValidationErrorNotValidYet + } + + if c.Subject == "" { + return fmt.Errorf("claim has no subject") + } + + if vErr.Errors > 0 { + return vErr + } + + return nil +} + +// ValidPrincipal checks if the jwtToken is signed correctly and validates with Claims. +func (j *JWT) ValidPrincipal(ctx context.Context, jwtToken Token, duration time.Duration) (Principal, error) { gojwt.TimeFunc = j.Now // Check for expected signing method. @@ -55,30 +95,74 @@ func (j *JWT) Authenticate(ctx context.Context, jwtToken string) (Principal, err return []byte(j.Secret), nil } + if duration == 0 { + return j.ValidEverlastingClaims(jwtToken, alg) + } + + return j.ValidClaims(jwtToken, duration, alg) +} + +// ValidClaims validates a token with StandardClaims +func (j *JWT) ValidClaims(jwtToken Token, duration time.Duration, alg gojwt.Keyfunc) (Principal, error) { // 1. Checks for expired tokens // 2. Checks if time is after the issued at // 3. Check if time is after not before (nbf) // 4. Check if subject is not empty - token, err := gojwt.ParseWithClaims(jwtToken, &Claims{}, alg) + // 5. Check if duration matches auth duration + token, err := gojwt.ParseWithClaims(string(jwtToken), &Claims{}, alg) if err != nil { return Principal{}, err + // at time of this writing and researching the docs, token.Valid seems to be always true } else if !token.Valid { return Principal{}, err } + // at time of this writing and researching the docs, there will always be claims claims, ok := token.Claims.(*Claims) if !ok { return Principal{}, fmt.Errorf("unable to convert claims to standard claims") } + if time.Duration(claims.ExpiresAt-claims.IssuedAt)*time.Second != duration { + return Principal{}, fmt.Errorf("claims duration is different from auth duration") + } + return Principal{ Subject: claims.Subject, Issuer: claims.Issuer, }, nil } -// Token creates a signed JWT token from user that expires at Now + duration -func (j *JWT) Token(ctx context.Context, user Principal, duration time.Duration) (string, error) { +// ValidEverlastingClaims validates a token with EverlastingClaims +func (j *JWT) ValidEverlastingClaims(jwtToken Token, alg gojwt.Keyfunc) (Principal, error) { + // 1. Checks if time is after the issued at + // 2. Check if time is after not before (nbf) + // 3. Check if subject is not empty + token, err := gojwt.ParseWithClaims(string(jwtToken), &EverlastingClaims{ + Now: j.Now, + }, alg) + if err != nil { + return Principal{}, err + // at time of this writing and researching the docs, token.Valid seems to be always true + } else if !token.Valid { + return Principal{}, err + } + + // at time of this writing and researching the docs, there will always be claims + claims, ok := token.Claims.(*EverlastingClaims) + if !ok { + return Principal{}, fmt.Errorf("unable to convert claims to everlasting claims") + } + + return Principal{ + Subject: claims.Subject, + Issuer: claims.Issuer, + }, nil +} + +// Create creates a signed JWT token from user that expires at Now + duration +func (j *JWT) Create(ctx context.Context, user Principal, duration time.Duration) (Token, error) { + gojwt.TimeFunc = j.Now // Create a new token object, specifying signing method and the claims // you would like it to contain. now := j.Now().UTC() @@ -92,7 +176,11 @@ func (j *JWT) Token(ctx context.Context, user Principal, duration time.Duration) }, } token := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims) - // Sign and get the complete encoded token as a string using the secret - return token.SignedString([]byte(j.Secret)) + t, err := token.SignedString([]byte(j.Secret)) + // this will only fail if the JSON can't be encoded correctly + if err != nil { + return "", err + } + return Token(t), nil } diff --git a/oauth2/jwt_test.go b/oauth2/jwt_test.go index d65a82800..987b06532 100644 --- a/oauth2/jwt_test.go +++ b/oauth2/jwt_test.go @@ -11,73 +11,96 @@ import ( func TestAuthenticate(t *testing.T) { var tests = []struct { - Desc string - Secret string - Token string - User oauth2.Principal - Err error + Desc string + Secret string + Token oauth2.Token + Duration time.Duration + Principal oauth2.Principal + Err error }{ { Desc: "Test bad jwt token", Secret: "secret", Token: "badtoken", - User: oauth2.Principal{ + Principal: oauth2.Principal{ Subject: "", }, Err: errors.New("token contains an invalid number of segments"), }, { - Desc: "Test valid jwt token", - Secret: "secret", - Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0NDAwLCJuYmYiOi00NDY3NzQ0MDB9._rZ4gOIei9PizHOABH6kLcJTA3jm8ls0YnDxtz1qeUI", - User: oauth2.Principal{ + Desc: "Test valid jwt token", + Secret: "secret", + Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0Mzk5LCJuYmYiOi00NDY3NzQ0MDB9.Ga0zGXWTT2CBVnnIhIO5tUAuBEVk4bKPaT4t4MU1ngo", + Duration: time.Second, + Principal: oauth2.Principal{ Subject: "/chronograf/v1/users/1", }, }, { - Desc: "Test expired jwt token", - Secret: "secret", - Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0NDAxLCJuYmYiOi00NDY3NzQ0MDB9.vWXdm0-XQ_pW62yBpSISFFJN_yz0vqT9_INcUKTp5Q8", - User: oauth2.Principal{ + Desc: "Test expired jwt token", + Secret: "secret", + Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0NDAxLCJuYmYiOi00NDY3NzQ0MDB9.vWXdm0-XQ_pW62yBpSISFFJN_yz0vqT9_INcUKTp5Q8", + Duration: time.Second, + Principal: oauth2.Principal{ Subject: "", }, Err: errors.New("token is expired by 1s"), }, { - Desc: "Test jwt token not before time", - Secret: "secret", - Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0NDAwLCJuYmYiOi00NDY3NzQzOTl9.TMGAhv57u1aosjc4ywKC7cElP1tKyQH7GmRF2ToAxlE", - User: oauth2.Principal{ + Desc: "Test jwt token not before time", + Secret: "secret", + Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0NDAwLCJuYmYiOi00NDY3NzQzOTl9.TMGAhv57u1aosjc4ywKC7cElP1tKyQH7GmRF2ToAxlE", + Duration: time.Second, + Principal: oauth2.Principal{ Subject: "", }, Err: errors.New("token is not valid yet"), }, { - Desc: "Test jwt with empty subject is invalid", - Secret: "secret", - Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOi00NDY3NzQ0MDAsImV4cCI6LTQ0Njc3NDQwMCwibmJmIjotNDQ2Nzc0NDAwfQ.gxsA6_Ei3s0f2I1TAtrrb8FmGiO25OqVlktlF_ylhX4", - User: oauth2.Principal{ + Desc: "Test jwt with empty subject is invalid", + Secret: "secret", + Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOi00NDY3NzQ0MDAsImV4cCI6LTQ0Njc3NDQwMCwibmJmIjotNDQ2Nzc0NDAwfQ.gxsA6_Ei3s0f2I1TAtrrb8FmGiO25OqVlktlF_ylhX4", + Duration: time.Second, + Principal: oauth2.Principal{ Subject: "", }, Err: errors.New("claim has no subject"), }, + { + Desc: "Test jwt duration matches auth duration", + Secret: "secret", + Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0NDAwLCJuYmYiOi00NDY3NzQ0MDB9._rZ4gOIei9PizHOABH6kLcJTA3jm8ls0YnDxtz1qeUI", + Duration: 500 * time.Hour, + Principal: oauth2.Principal{ + Subject: "/chronograf/v1/users/1", + }, + Err: errors.New("claims duration is different from auth duration"), + }, + { + Desc: "Test valid EverlastingClaim", + Secret: "secret", + Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0Mzk5LCJuYmYiOi00NDY3NzQ0MDB9.Ga0zGXWTT2CBVnnIhIO5tUAuBEVk4bKPaT4t4MU1ngo", + Principal: oauth2.Principal{ + Subject: "/chronograf/v1/users/1", + }, + }, } - for i, test := range tests { + for _, test := range tests { j := oauth2.JWT{ Secret: test.Secret, Now: func() time.Time { return time.Unix(-446774400, 0) }, } - user, err := j.Authenticate(context.Background(), test.Token) + principal, err := j.ValidPrincipal(context.Background(), test.Token, test.Duration) if err != nil { if test.Err == nil { - t.Errorf("Error in test %d authenticating with bad token: %v", i, err) + t.Errorf("Error in test %s authenticating with bad token: %v", test.Desc, err) } else if err.Error() != test.Err.Error() { - t.Errorf("Error in test %d expected error: %v actual: %v", i, err, test.Err) + t.Errorf("Error in test %s expected error: %v actual: %v", test.Desc, test.Err, err) } - } else if test.User != user { - t.Errorf("Error in test %d; users different; expected: %v actual: %v", i, test.User, user) + } else if test.Principal != principal { + t.Errorf("Error in test %s; principals different; expected: %v actual: %v", test.Desc, test.Principal, principal) } } @@ -85,7 +108,7 @@ func TestAuthenticate(t *testing.T) { func TestToken(t *testing.T) { duration := time.Second - expected := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOi00NDY3NzQzOTksImlhdCI6LTQ0Njc3NDQwMCwibmJmIjotNDQ2Nzc0NDAwLCJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIn0.ofQM6yTmrmve5JeEE0RcK4_euLXuZ_rdh6bLAbtbC9M" + expected := oauth2.Token("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOi00NDY3NzQzOTksImlhdCI6LTQ0Njc3NDQwMCwibmJmIjotNDQ2Nzc0NDAwLCJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIn0.ofQM6yTmrmve5JeEE0RcK4_euLXuZ_rdh6bLAbtbC9M") j := oauth2.JWT{ Secret: "secret", Now: func() time.Time { @@ -95,9 +118,19 @@ func TestToken(t *testing.T) { p := oauth2.Principal{ Subject: "/chronograf/v1/users/1", } - if token, err := j.Token(context.Background(), p, duration); err != nil { - t.Errorf("Error creating token for user: %v", err) + if token, err := j.Create(context.Background(), p, duration); err != nil { + t.Errorf("Error creating token for principal: %v", err) } else if token != expected { - t.Errorf("Error creating token; expected: %s actual: %s", "", token) + t.Errorf("Error creating token; expected: %s actual: %s", expected, token) + } +} + +func TestSigningMethod(t *testing.T) { + token := oauth2.Token("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.EkN-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8jO19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHGuERTqYZyuhtF39yxJPAjUESwxk2J5k_4zM3O-vtd1Ghyo4IbqKKSy6J9mTniYJPenn5-HIirE") + j := oauth2.JWT{} + if _, err := j.ValidPrincipal(context.Background(), token, 0); err == nil { + t.Error("Error was expected while validating incorrectly signed token") + } else if err.Error() != "unexpected signing method: RS256" { + t.Errorf("Error wanted 'unexpected signing method', got %s", err.Error()) } } diff --git a/oauth2/mux.go b/oauth2/mux.go index 9ecc11dba..0236ecdff 100644 --- a/oauth2/mux.go +++ b/oauth2/mux.go @@ -8,69 +8,56 @@ import ( "golang.org/x/oauth2" ) -const ( - // DefaultCookieName is the name of the stored cookie - DefaultCookieName = "session" - // DefaultCookieDuration is the length of time the cookie is valid - DefaultCookieDuration = time.Hour * 24 * 30 -) +// Check to ensure AuthMux is an oauth2.Mux +var _ Mux = &AuthMux{} -// Cookie represents the location and expiration time of new cookies. -type cookie struct { - Name string - Duration time.Duration -} +// TenMinutes is the default length of time to get a response back from the OAuth provider +const TenMinutes = 10 * time.Minute -// Check to ensure CookieMux is an oauth2.Mux -var _ Mux = &CookieMux{} - -// NewCookieMux constructs a Mux handler that checks a cookie against the authenticator -func NewCookieMux(p Provider, a Authenticator, l chronograf.Logger) *CookieMux { - return &CookieMux{ +// NewAuthMux constructs a Mux handler that checks a cookie against the authenticator +func NewAuthMux(p Provider, a Authenticator, t Tokenizer, l chronograf.Logger) *AuthMux { + return &AuthMux{ Provider: p, Auth: a, + Tokens: t, Logger: l, SuccessURL: "/", FailureURL: "/login", - Now: time.Now, - - cookie: cookie{ - Name: DefaultCookieName, - Duration: DefaultCookieDuration, - }, } } -// CookieMux services an Oauth2 interaction with a provider and browser and +// AuthMux services an Oauth2 interaction with a provider and browser and // stores the resultant token in the user's browser as a cookie. The benefit of // this is that the cookie's authenticity can be verified independently by any // Chronograf instance as long as the Authenticator has no external // dependencies (e.g. on a Database). -type CookieMux struct { - Provider Provider - Auth Authenticator - cookie cookie - Logger chronograf.Logger - SuccessURL string // SuccessURL is redirect location after successful authorization - FailureURL string // FailureURL is redirect location after authorization failure - Now func() time.Time // Now returns the current time +type AuthMux struct { + Provider Provider // Provider is the OAuth2 service + Auth Authenticator // Auth is used to Authorize after successful OAuth2 callback and Expire on Logout + Tokens Tokenizer // Tokens is used to create and validate OAuth2 "state" + Logger chronograf.Logger // Logger is used to give some more information about the OAuth2 process + SuccessURL string // SuccessURL is redirect location after successful authorization + FailureURL string // FailureURL is redirect location after authorization failure } // Login uses a Cookie with a random string as the state validation method. JWTs are // a good choice here for encoding because they can be validated without -// storing state. -func (j *CookieMux) Login() http.Handler { +// storing state. Login returns a handler that redirects to the providers OAuth login. +func (j *AuthMux) Login() http.Handler { conf := j.Provider.Config() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // We are creating a token with an encoded random string to prevent CSRF attacks // This token will be validated during the OAuth callback. - // We'll give our users 10 minutes from this point to type in their github password. + // We'll give our users 10 minutes from this point to type in their + // oauth2 provider's password. // If the callback is not received within 10 minutes, then authorization will fail. csrf := randomString(32) // 32 is not important... just long p := Principal{ Subject: csrf, } - state, err := j.Auth.Token(r.Context(), p, 10*time.Minute) + // This token will be valid for 10 minutes. Any chronograf server will + // be able to validate this token. + token, err := j.Tokens.Create(r.Context(), p, TenMinutes) // This is likely an internal server error if err != nil { j.Logger. @@ -82,7 +69,7 @@ func (j *CookieMux) Login() http.Handler { http.Error(w, err.Error(), http.StatusInternalServerError) return } - url := conf.AuthCodeURL(state, oauth2.AccessTypeOnline) + url := conf.AuthCodeURL(string(token), oauth2.AccessTypeOnline) http.Redirect(w, r, url, http.StatusTemporaryRedirect) }) } @@ -92,7 +79,7 @@ func (j *CookieMux) Login() http.Handler { // recommended that the value of the cookie be encoded as a JWT because the JWT // can be validated without the need for saving state. The JWT contains the // principal's identifier (e.g. email address). -func (j *CookieMux) Callback() http.Handler { +func (j *AuthMux) Callback() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log := j.Logger. WithField("component", "auth"). @@ -102,8 +89,10 @@ func (j *CookieMux) Callback() http.Handler { state := r.FormValue("state") // Check if the OAuth state token is valid to prevent CSRF - _, err := j.Auth.Authenticate(r.Context(), state) - if err != nil { + // The state variable we set is actually a token. We'll check + // if the token is valid. We don't need to know anything + // about the contents of the principal only that it hasn't expired. + if _, err := j.Tokens.ValidPrincipal(r.Context(), Token(state), TenMinutes); err != nil { log.Error("Invalid OAuth state received: ", err.Error()) http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) return @@ -132,39 +121,22 @@ func (j *CookieMux) Callback() http.Handler { Subject: id, Issuer: j.Provider.Name(), } - // We create an auth token that will be used by all other endpoints to validate the principal has a claim - authToken, err := j.Auth.Token(r.Context(), p, j.cookie.Duration) + ctx := r.Context() + err = j.Auth.Authorize(ctx, w, p) if err != nil { - log.Error("Unable to create cookie auth token ", err.Error()) + log.Error("Unable to get add session to response ", err.Error()) http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) return } - - expireCookie := j.Now().UTC().Add(j.cookie.Duration) - cookie := http.Cookie{ - Name: j.cookie.Name, - Value: authToken, - Expires: expireCookie, - HttpOnly: true, - Path: "/", - } log.Info("User ", id, " is authenticated") - http.SetCookie(w, &cookie) - http.Redirect(w, r, j.SuccessURL, http.StatusTemporaryRedirect) - }) -} // Login returns a handler that redirects to the providers OAuth login. - -// Logout handler will expire our authentication cookie and redirect to the successURL -func (j *CookieMux) Logout() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - deleteCookie := http.Cookie{ - Name: j.cookie.Name, - Value: "none", - Expires: j.Now().UTC().Add(-1 * time.Hour), - HttpOnly: true, - Path: "/", - } - http.SetCookie(w, &deleteCookie) + http.Redirect(w, r, j.SuccessURL, http.StatusTemporaryRedirect) + }) +} + +// Logout handler will expire our authentication cookie and redirect to the successURL +func (j *AuthMux) Logout() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + j.Auth.Expire(w) http.Redirect(w, r, j.SuccessURL, http.StatusTemporaryRedirect) }) } diff --git a/oauth2/mux_test.go b/oauth2/mux_test.go index bb4403ca6..acdf81c01 100644 --- a/oauth2/mux_test.go +++ b/oauth2/mux_test.go @@ -1,4 +1,4 @@ -package oauth2_test +package oauth2 import ( "net/http" @@ -9,34 +9,36 @@ import ( "time" clog "github.com/influxdata/chronograf/log" - "github.com/influxdata/chronograf/oauth2" ) -var testTime time.Time = time.Date(1985, time.October, 25, 18, 0, 0, 0, time.UTC) +var testTime = time.Date(1985, time.October, 25, 18, 0, 0, 0, time.UTC) // setupMuxTest produces an http.Client and an httptest.Server configured to -// use a particular http.Handler selected from a CookieMux. As this selection is +// use a particular http.Handler selected from a AuthMux. As this selection is // done during the setup process, this configuration is performed by providing // a function, and returning the desired handler. Cleanup is still the // responsibility of the test writer, so the httptest.Server's Close() method // should be deferred. -func setupMuxTest(selector func(*oauth2.CookieMux) http.Handler) (*http.Client, *httptest.Server, *httptest.Server) { +func setupMuxTest(selector func(*AuthMux) http.Handler) (*http.Client, *httptest.Server, *httptest.Server) { provider := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusOK) })) - mp := &MockProvider{"biff@example.com", provider.URL} - - jm := oauth2.NewCookieMux(mp, &YesManAuthenticator{}, clog.New(clog.ParseLevel("debug"))) - - jm.Now = func() time.Time { + now := func() time.Time { return testTime } + mp := &MockProvider{"biff@example.com", provider.URL} + mt := &YesManTokenizer{} + auth := &cookie{ + Name: DefaultCookieName, + Duration: 1 * time.Hour, + Now: now, + Tokens: mt, + } + jm := NewAuthMux(mp, auth, mt, clog.New(clog.ParseLevel("debug"))) ts := httptest.NewServer(selector(jm)) - jar, _ := cookiejar.New(nil) - hc := http.Client{ Jar: jar, CheckRedirect: func(r *http.Request, via []*http.Request) error { @@ -53,19 +55,19 @@ func teardownMuxTest(hc *http.Client, backend *httptest.Server, provider *httpte backend.Close() } -func Test_CookieMux_Logout_DeletesSessionCookie(t *testing.T) { +func Test_AuthMux_Logout_DeletesSessionCookie(t *testing.T) { t.Parallel() - hc, ts, prov := setupMuxTest(func(j *oauth2.CookieMux) http.Handler { + hc, ts, prov := setupMuxTest(func(j *AuthMux) http.Handler { return j.Logout() }) defer teardownMuxTest(hc, ts, prov) - tsUrl, _ := url.Parse(ts.URL) + tsURL, _ := url.Parse(ts.URL) - hc.Jar.SetCookies(tsUrl, []*http.Cookie{ + hc.Jar.SetCookies(tsURL, []*http.Cookie{ &http.Cookie{ - Name: oauth2.DefaultCookieName, + Name: DefaultCookieName, Value: "", }, }) @@ -85,15 +87,15 @@ func Test_CookieMux_Logout_DeletesSessionCookie(t *testing.T) { } c := cookies[0] - if c.Name != oauth2.DefaultCookieName || c.Expires != testTime.Add(-1*time.Hour) { + if c.Name != DefaultCookieName || c.Expires != testTime.Add(-1*time.Hour) { t.Fatal("Expected cookie to be expired but wasn't") } } -func Test_CookieMux_Login_RedirectsToCorrectURL(t *testing.T) { +func Test_AuthMux_Login_RedirectsToCorrectURL(t *testing.T) { t.Parallel() - hc, ts, prov := setupMuxTest(func(j *oauth2.CookieMux) http.Handler { + hc, ts, prov := setupMuxTest(func(j *AuthMux) http.Handler { return j.Login() // Use Login handler for httptest server. }) defer teardownMuxTest(hc, ts, prov) @@ -114,12 +116,12 @@ func Test_CookieMux_Login_RedirectsToCorrectURL(t *testing.T) { } if state := loc.Query().Get("state"); state != "HELLO?!MCFLY?!ANYONEINTHERE?!" { - t.Fatal("Expected state to be set but was", state) + t.Fatalf("Expected state to be %s set but was %s", "HELLO?!MCFLY?!ANYONEINTHERE?!", state) } } -func Test_CookieMux_Callback_SetsCookie(t *testing.T) { - hc, ts, prov := setupMuxTest(func(j *oauth2.CookieMux) http.Handler { +func Test_AuthMux_Callback_SetsCookie(t *testing.T) { + hc, ts, prov := setupMuxTest(func(j *AuthMux) http.Handler { return j.Callback() }) defer teardownMuxTest(hc, ts, prov) @@ -151,7 +153,7 @@ func Test_CookieMux_Callback_SetsCookie(t *testing.T) { c := cookies[0] - if c.Name != oauth2.DefaultCookieName { - t.Fatal("Expected cookie to be named", oauth2.DefaultCookieName, "but was", c.Name) + if c.Name != DefaultCookieName { + t.Fatal("Expected cookie to be named", DefaultCookieName, "but was", c.Name) } } diff --git a/oauth2/oauth2.go b/oauth2/oauth2.go index 393359f20..e7757573c 100644 --- a/oauth2/oauth2.go +++ b/oauth2/oauth2.go @@ -48,7 +48,6 @@ type Provider interface { Config() *oauth2.Config // PrincipalID with fetch the identifier to be associated with the principal. PrincipalID(provider *http.Client) (string, error) - // Name is the name of the Provider Name() string } @@ -62,14 +61,26 @@ type Mux interface { // Authenticator represents a service for authenticating users. type Authenticator interface { - // Authenticate returns User associated with token if successful. - Authenticate(ctx context.Context, token string) (Principal, error) - // Token generates a valid token for Principal lasting a duration - Token(context.Context, Principal, time.Duration) (string, error) + // Validate returns Principal associated with authenticated and authorized + // entity if successful. + Validate(context.Context, *http.Request) (Principal, error) + // Authorize will grant privileges to a Principal + Authorize(context.Context, http.ResponseWriter, Principal) error + // Expire revokes privileges from a Principal + Expire(http.ResponseWriter) } -// TokenExtractor extracts tokens from http requests -type TokenExtractor interface { - // Extract will return the token or an error. - Extract(r *http.Request) (string, error) +// Token represents a time-dependent reference (i.e. identifier) that maps back +// to the sensitive data through a tokenization system +type Token string + +// Tokenizer substitutes a sensitive data element (Principal) with a +// non-sensitive equivalent, referred to as a token, that has no extrinsic +// or exploitable meaning or value. +type Tokenizer interface { + // Create uses a token lasting duration with Principal data + Create(context.Context, Principal, time.Duration) (Token, error) + // ValidPrincipal checks if the token has a valid Principal and requires + // a duration to ensure it complies with possible server runtime arguments. + ValidPrincipal(context.Context, Token, time.Duration) (Principal, error) } diff --git a/oauth2/oauth2_test.go b/oauth2/oauth2_test.go index ed6379414..a5bc026cd 100644 --- a/oauth2/oauth2_test.go +++ b/oauth2/oauth2_test.go @@ -1,4 +1,4 @@ -package oauth2_test +package oauth2 import ( "context" @@ -10,10 +10,9 @@ import ( goauth "golang.org/x/oauth2" "github.com/influxdata/chronograf" - "github.com/influxdata/chronograf/oauth2" ) -var _ oauth2.Provider = &MockProvider{} +var _ Provider = &MockProvider{} type MockProvider struct { Email string @@ -53,19 +52,19 @@ func (mp *MockProvider) Secret() string { return "4815162342" } -var _ oauth2.Authenticator = &YesManAuthenticator{} +var _ Tokenizer = &YesManTokenizer{} -type YesManAuthenticator struct{} +type YesManTokenizer struct{} -func (y *YesManAuthenticator) Authenticate(ctx context.Context, token string) (oauth2.Principal, error) { - return oauth2.Principal{ +func (y *YesManTokenizer) ValidPrincipal(ctx context.Context, token Token, duration time.Duration) (Principal, error) { + return Principal{ Subject: "biff@example.com", Issuer: "Biff Tannen's Pleasure Paradise", }, nil } -func (y *YesManAuthenticator) Token(ctx context.Context, p oauth2.Principal, t time.Duration) (string, error) { - return "HELLO?!MCFLY?!ANYONEINTHERE?!", nil +func (y *YesManTokenizer) Create(ctx context.Context, p Principal, t time.Duration) (Token, error) { + return Token("HELLO?!MCFLY?!ANYONEINTHERE?!"), nil } func NewTestTripper(log chronograf.Logger, ts *httptest.Server, rt http.RoundTripper) (*TestTripper, error) { diff --git a/server/auth.go b/server/auth.go new file mode 100644 index 000000000..da5cda9d9 --- /dev/null +++ b/server/auth.go @@ -0,0 +1,38 @@ +package server + +import ( + "context" + "net/http" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/oauth2" +) + +// AuthorizedToken extracts the token and validates; if valid the next handler +// will be run. The principal will be sent to the next handler via the request's +// Context. It is up to the next handler to determine if the principal has access. +// On failure, will return http.StatusForbidden. +func AuthorizedToken(auth oauth2.Authenticator, logger chronograf.Logger, next http.Handler) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log := logger. + WithField("component", "auth"). + WithField("remote_addr", r.RemoteAddr). + WithField("method", r.Method). + WithField("url", r.URL) + + ctx := r.Context() + // We do not check the validity of the principal. Those + // served further down the chain should do so. + principal, err := auth.Validate(ctx, r) + if err != nil { + log.Error("Invalid principal") + w.WriteHeader(http.StatusForbidden) + return + } + + // Send the principal to the next handler + ctx = context.WithValue(ctx, oauth2.PrincipalKey, principal) + next.ServeHTTP(w, r.WithContext(ctx)) + return + }) +} diff --git a/server/auth_test.go b/server/auth_test.go new file mode 100644 index 000000000..6dd7336e5 --- /dev/null +++ b/server/auth_test.go @@ -0,0 +1,85 @@ +package server_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + clog "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/oauth2" + "github.com/influxdata/chronograf/server" +) + +type MockAuthenticator struct { + Principal oauth2.Principal + ValidateErr error + Serialized string +} + +func (m *MockAuthenticator) Validate(context.Context, *http.Request) (oauth2.Principal, error) { + return m.Principal, m.ValidateErr +} +func (m *MockAuthenticator) Authorize(ctx context.Context, w http.ResponseWriter, p oauth2.Principal) error { + cookie := http.Cookie{} + + http.SetCookie(w, &cookie) + return nil +} +func (m *MockAuthenticator) Expire(http.ResponseWriter) {} +func (m *MockAuthenticator) ValidAuthorization(ctx context.Context, serializedAuthorization string) (oauth2.Principal, error) { + return oauth2.Principal{}, nil +} +func (m *MockAuthenticator) Serialize(context.Context, oauth2.Principal) (string, error) { + return m.Serialized, nil +} + +func TestAuthorizedToken(t *testing.T) { + var tests = []struct { + Desc string + Code int + Principal oauth2.Principal + ValidateErr error + Expected string + }{ + { + Desc: "Error in validate", + Code: http.StatusForbidden, + ValidateErr: errors.New("error"), + }, + { + Desc: "Authorized ok", + Code: http.StatusOK, + Principal: oauth2.Principal{ + Subject: "Principal Strickland", + }, + Expected: "Principal Strickland", + }, + } + for _, test := range tests { + // next is a sentinel StatusOK and + // principal recorder. + var principal oauth2.Principal + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + principal = r.Context().Value(oauth2.PrincipalKey).(oauth2.Principal) + }) + req, _ := http.NewRequest("GET", "", nil) + w := httptest.NewRecorder() + + a := &MockAuthenticator{ + Principal: test.Principal, + ValidateErr: test.ValidateErr, + } + + logger := clog.New(clog.DebugLevel) + handler := server.AuthorizedToken(a, logger, next) + handler.ServeHTTP(w, req) + if w.Code != test.Code { + t.Errorf("Status code expected: %d actual %d", test.Code, w.Code) + } else if principal != test.Principal { + t.Errorf("Principal mismatch expected: %s actual %s", test.Principal, principal) + } + } +} diff --git a/server/dashboards.go b/server/dashboards.go index 0a0a71eac..73705a645 100644 --- a/server/dashboards.go +++ b/server/dashboards.go @@ -365,7 +365,7 @@ func (s *Service) NewDashboardCell(w http.ResponseWriter, r *http.Request) { dash.Cells = append(dash.Cells, cell) if err := s.DashboardsStore.Update(ctx, dash); err != nil { - msg := fmt.Sprintf("Error updating dashboard ID %d: %v", id, err) + msg := fmt.Sprintf("Error adding cell %s to dashboard %d: %v", cid, id, err) Error(w, http.StatusInternalServerError, msg, s.Logger) return } @@ -435,7 +435,7 @@ func (s *Service) RemoveDashboardCell(w http.ResponseWriter, r *http.Request) { dash.Cells = append(dash.Cells[:cellid], dash.Cells[cellid+1:]...) if err := s.DashboardsStore.Update(ctx, dash); err != nil { - msg := fmt.Sprintf("Error updating dashboard ID %d: %v", id, err) + msg := fmt.Sprintf("Error removing cell %s from dashboard %d: %v", cid, id, err) Error(w, http.StatusInternalServerError, msg, s.Logger) return } @@ -484,7 +484,7 @@ func (s *Service) ReplaceDashboardCell(w http.ResponseWriter, r *http.Request) { dash.Cells[cellid] = cell if err := s.DashboardsStore.Update(ctx, dash); err != nil { - msg := fmt.Sprintf("Error updating dashboard ID %d: %v", id, err) + msg := fmt.Sprintf("Error updating cell %s in dashboard %d: %v", cid, id, err) Error(w, http.StatusInternalServerError, msg, s.Logger) return } diff --git a/server/dashboards_test.go b/server/dashboards_test.go index 55547f5fb..2d1c3c034 100644 --- a/server/dashboards_test.go +++ b/server/dashboards_test.go @@ -246,7 +246,7 @@ func Test_newDashboardResponse(t *testing.T) { { Command: "SELECT donors from hill_valley_preservation_society where time > '1985-10-25 08:00:00'", QueryConfig: chronograf.QueryConfig{ - RawText: "SELECT donors FROM hill_valley_preservation_society WHERE time > '1985-10-25 08:00:00'", + RawText: "SELECT donors from hill_valley_preservation_society where time > '1985-10-25 08:00:00'", Fields: []chronograf.Field{}, GroupBy: chronograf.GroupBy{ Tags: []string{}, diff --git a/server/mux.go b/server/mux.go index a32345e10..c229f811c 100644 --- a/server/mux.go +++ b/server/mux.go @@ -20,13 +20,12 @@ const ( // MuxOpts are the options for the router. Mostly related to auth. type MuxOpts struct { - Logger chronograf.Logger - Develop bool // Develop loads assets from filesystem instead of bindata - Basepath string // URL path prefix under which all chronograf routes will be mounted - PrefixRoutes bool // Mounts all backend routes under route specified by the Basepath - UseAuth bool // UseAuth turns on Github OAuth and JWT - TokenSecret string - + Logger chronograf.Logger + Develop bool // Develop loads assets from filesystem instead of bindata + Basepath string // URL path prefix under which all chronograf routes will be mounted + PrefixRoutes bool // Mounts all backend routes under route specified by the Basepath + UseAuth bool // UseAuth turns on Github OAuth and JWT + Auth oauth2.Authenticator // Auth is used to authenticate and authorize ProviderFuncs []func(func(oauth2.Provider, oauth2.Mux)) } @@ -192,9 +191,7 @@ func NewMux(opts MuxOpts, service Service) http.Handler { } // AuthAPI adds the OAuth routes if auth is enabled. -// TODO: this function is not great. Would be good if providers added their routes. func AuthAPI(opts MuxOpts, router chronograf.Router) (http.Handler, AuthRoutes) { - auth := oauth2.NewJWT(opts.TokenSecret) routes := AuthRoutes{} for _, pf := range opts.ProviderFuncs { pf(func(p oauth2.Provider, m oauth2.Mux) { @@ -214,7 +211,7 @@ func AuthAPI(opts MuxOpts, router chronograf.Router) (http.Handler, AuthRoutes) }) } - tokenMiddleware := oauth2.AuthorizedToken(&auth, &oauth2.CookieExtractor{Name: "session"}, opts.Logger, router) + tokenMiddleware := AuthorizedToken(opts.Auth, opts.Logger, router) // Wrap the API with token validation middleware. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/chronograf/v1/") || r.URL.Path == "/oauth/logout" { diff --git a/server/proxy.go b/server/proxy.go index 5e1a0fa85..b00709e07 100644 --- a/server/proxy.go +++ b/server/proxy.go @@ -44,6 +44,9 @@ func (h *Service) KapacitorProxy(w http.ResponseWriter, r *http.Request) { u.Path = path director := func(req *http.Request) { + // Set the Host header of the original Kapacitor URL + req.Host = u.Host + req.URL = u // Because we are acting as a proxy, kapacitor needs to have the basic auth information set as // a header directly diff --git a/server/routes_test.go b/server/routes_test.go new file mode 100644 index 000000000..3597758f5 --- /dev/null +++ b/server/routes_test.go @@ -0,0 +1,30 @@ +package server + +import ( + "encoding/json" + "io/ioutil" + "net/http/httptest" + "testing" + + "github.com/influxdata/chronograf/log" +) + +func TestAllRoutes(t *testing.T) { + logger := log.New(log.DebugLevel) + handler := AllRoutes([]AuthRoute{}, logger) + req := httptest.NewRequest("GET", "http://docbrowns-inventions.com", nil) + w := httptest.NewRecorder() + handler(w, req) + + resp := w.Result() + body, err := ioutil.ReadAll(resp.Body) + defer resp.Body.Close() + + if err != nil { + t.Error("TestAllRoutes not able to retrieve body") + } + var routes getRoutesResponse + if err := json.Unmarshal(body, &routes); err != nil { + t.Error("TestAllRoutes not able to unmarshal JSON response") + } +} diff --git a/server/server.go b/server/server.go index bb77d6006..db4fb0cc4 100644 --- a/server/server.go +++ b/server/server.go @@ -48,10 +48,11 @@ type Server struct { KapacitorUsername string `long:"kapacitor-username" description:"Username of your Kapacitor instance" env:"KAPACITOR_USERNAME"` KapacitorPassword string `long:"kapacitor-password" description:"Password of your Kapacitor instance" env:"KAPACITOR_PASSWORD"` - Develop bool `short:"d" long:"develop" description:"Run server in develop mode."` - BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (/var/lib/chronograf/chronograf-v1.db)" env:"BOLT_PATH" default:"chronograf-v1.db"` - CannedPath string `short:"c" long:"canned-path" description:"Path to directory of pre-canned application layouts (/usr/share/chronograf/canned)" env:"CANNED_PATH" default:"canned"` - TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"` + Develop bool `short:"d" long:"develop" description:"Run server in develop mode."` + BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (/var/lib/chronograf/chronograf-v1.db)" env:"BOLT_PATH" default:"chronograf-v1.db"` + CannedPath string `short:"c" long:"canned-path" description:"Path to directory of pre-canned application layouts (/usr/share/chronograf/canned)" env:"CANNED_PATH" default:"canned"` + TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"` + AuthDuration time.Duration `long:"auth-duration" default:"720h" description:"Total duration of cookie life for authentication (in hours). 0 means authentication expires on browser close." env:"AUTH_DURATION"` GithubClientID string `short:"i" long:"github-client-id" description:"Github Client ID for OAuth 2 support" env:"GH_CLIENT_ID"` GithubClientSecret string `short:"s" long:"github-client-secret" description:"Github Client Secret for OAuth 2 support" env:"GH_CLIENT_SECRET"` @@ -106,7 +107,8 @@ func (s *Server) githubOAuth(logger chronograf.Logger, auth oauth2.Authenticator Orgs: s.GithubOrgs, Logger: logger, } - ghMux := oauth2.NewCookieMux(&gh, auth, logger) + jwt := oauth2.NewJWT(s.TokenSecret) + ghMux := oauth2.NewAuthMux(&gh, auth, jwt, logger) return &gh, ghMux, s.UseGithub } @@ -119,8 +121,8 @@ func (s *Server) googleOAuth(logger chronograf.Logger, auth oauth2.Authenticator RedirectURL: redirectURL, Logger: logger, } - - goMux := oauth2.NewCookieMux(&google, auth, logger) + jwt := oauth2.NewJWT(s.TokenSecret) + goMux := oauth2.NewAuthMux(&google, auth, jwt, logger) return &google, goMux, s.UseGoogle } @@ -131,8 +133,8 @@ func (s *Server) herokuOAuth(logger chronograf.Logger, auth oauth2.Authenticator Organizations: s.HerokuOrganizations, Logger: logger, } - - hMux := oauth2.NewCookieMux(&heroku, auth, logger) + jwt := oauth2.NewJWT(s.TokenSecret) + hMux := oauth2.NewAuthMux(&heroku, auth, jwt, logger) return &heroku, hMux, s.UseHeroku } @@ -207,14 +209,14 @@ func (s *Server) Serve(ctx context.Context) error { providerFuncs := []func(func(oauth2.Provider, oauth2.Mux)){} - auth := oauth2.NewJWT(s.TokenSecret) - providerFuncs = append(providerFuncs, provide(s.githubOAuth(logger, &auth))) - providerFuncs = append(providerFuncs, provide(s.googleOAuth(logger, &auth))) - providerFuncs = append(providerFuncs, provide(s.herokuOAuth(logger, &auth))) + auth := oauth2.NewCookieJWT(s.TokenSecret, s.AuthDuration) + providerFuncs = append(providerFuncs, provide(s.githubOAuth(logger, auth))) + providerFuncs = append(providerFuncs, provide(s.googleOAuth(logger, auth))) + providerFuncs = append(providerFuncs, provide(s.herokuOAuth(logger, auth))) s.handler = NewMux(MuxOpts{ Develop: s.Develop, - TokenSecret: s.TokenSecret, + Auth: auth, Logger: logger, UseAuth: s.useAuth(), ProviderFuncs: providerFuncs, diff --git a/ui/src/dashboards/actions/index.js b/ui/src/dashboards/actions/index.js index 3258f0376..f60b2395a 100644 --- a/ui/src/dashboards/actions/index.js +++ b/ui/src/dashboards/actions/index.js @@ -64,9 +64,10 @@ export const syncDashboardCell = (dashboard, cell) => ({ }, }) -export const addDashboardCell = (cell) => ({ +export const addDashboardCell = (dashboard, cell) => ({ type: 'ADD_DASHBOARD_CELL', payload: { + dashboard, cell, }, }) @@ -141,7 +142,7 @@ export const deleteDashboardAsync = (dashboard) => async (dispatch) => { export const addDashboardCellAsync = (dashboard) => async (dispatch) => { try { const {data} = await addDashboardCellAJAX(dashboard, NEW_DEFAULT_DASHBOARD_CELL) - dispatch(addDashboardCell(data)) + dispatch(addDashboardCell(dashboard, data)) } catch (error) { console.error(error) throw error diff --git a/ui/src/dashboards/constants/index.js b/ui/src/dashboards/constants/index.js index 8ace203ba..8ede7d3c0 100644 --- a/ui/src/dashboards/constants/index.js +++ b/ui/src/dashboards/constants/index.js @@ -12,29 +12,17 @@ export const EMPTY_DASHBOARD = { ], } -export const NEW_DASHBOARD = { - name: 'Name This Dashboard', - cells: [ - { - x: 0, - y: 0, - w: 4, - h: 4, - name: 'Name This Graph', - type: 'line', - queries: [ - { - query: "SELECT mean(\"usage_user\") AS \"usage_user\" FROM \"cpu\"", - label: "", - groupbys: [], - wheres: [], - }, - ], - }, - ], +export const NEW_DEFAULT_DASHBOARD_CELL = { + x: 0, + y: 0, + w: 4, + h: 4, + name: 'Name This Graph', + type: 'line', + queries: [], } -export const NEW_DEFAULT_DASHBOARD_CELL = { - query: [], - type: 'line', +export const NEW_DASHBOARD = { + name: 'Name This Dashboard', + cells: [NEW_DEFAULT_DASHBOARD_CELL], } diff --git a/ui/src/dashboards/containers/DashboardPage.js b/ui/src/dashboards/containers/DashboardPage.js index 8f1b8fc44..726667941 100644 --- a/ui/src/dashboards/containers/DashboardPage.js +++ b/ui/src/dashboards/containers/DashboardPage.js @@ -103,9 +103,9 @@ const DashboardPage = React.createClass({ }, handleUpdatePosition(cells) { - const dashboard = this.getActiveDashboard() - this.props.dashboardActions.updateDashboardCells(dashboard, cells) - this.props.dashboardActions.putDashboard(dashboard) + const newDashboard = {...this.getActiveDashboard(), cells} + this.props.dashboardActions.updateDashboard(newDashboard) + this.props.dashboardActions.putDashboard(newDashboard) }, handleAddCell() { diff --git a/ui/src/dashboards/reducers/ui.js b/ui/src/dashboards/reducers/ui.js index 2f7b15cab..5156c5da4 100644 --- a/ui/src/dashboards/reducers/ui.js +++ b/ui/src/dashboards/reducers/ui.js @@ -72,15 +72,13 @@ export default function ui(state = initialState, action) { } case 'ADD_DASHBOARD_CELL': { - const {cell} = action.payload - const {dashboard, dashboards} = state + const {cell, dashboard} = action.payload + const {dashboards} = state const newCells = [cell, ...dashboard.cells] const newDashboard = {...dashboard, cells: newCells} const newDashboards = dashboards.map((d) => d.id === dashboard.id ? newDashboard : d) - const newState = { - dashboards: newDashboards, - } + const newState = {dashboards: newDashboards} return {...state, ...newState} } diff --git a/ui/src/index.js b/ui/src/index.js index 2596631aa..244b9b53d 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -21,10 +21,13 @@ import {getMe, getSources} from 'shared/apis' import {receiveMe} from 'shared/actions/me' import {receiveAuth} from 'shared/actions/auth' import {disablePresentationMode} from 'shared/actions/app' +import {publishNotification} from 'shared/actions/notifications' import {loadLocalStorage} from './localStorage' import 'src/style/chronograf.scss' +import {HTTP_FORBIDDEN, HEARTBEAT_INTERVAL} from 'shared/constants' + const store = configureStore(loadLocalStorage()) const rootNode = document.getElementById('react-root') @@ -81,17 +84,32 @@ const Root = React.createClass({ if (store.getState().me.links) { return this.setState({loggedIn: true}) } - getMe().then(({data: me, auth}) => { - store.dispatch(receiveMe(me)) - store.dispatch(receiveAuth(auth)) - this.setState({loggedIn: true}) - }).catch((error) => { + + this.heartbeat({shouldDispatchResponse: true}) + }, + + async heartbeat({shouldDispatchResponse}) { + try { + const {data: me, auth} = await getMe() + if (shouldDispatchResponse) { + store.dispatch(receiveMe(me)) + store.dispatch(receiveAuth(auth)) + this.setState({loggedIn: true}) + } + + setTimeout(this.heartbeat.bind(null, {shouldDispatchResponse: false}), HEARTBEAT_INTERVAL) + } catch (error) { if (error.auth) { store.dispatch(receiveAuth(error.auth)) } + if (error.status === HTTP_FORBIDDEN) { + store.dispatch(publishNotification('error', 'Session timed out. Please login again.')) + } else { + store.dispatch(publishNotification('error', 'Cannot communicate with server.')) + } this.setState({loggedIn: false}) - }) + } }, render() { diff --git a/ui/src/kapacitor/components/HipChatConfig.js b/ui/src/kapacitor/components/HipChatConfig.js index ff1d400f8..42c392e93 100644 --- a/ui/src/kapacitor/components/HipChatConfig.js +++ b/ui/src/kapacitor/components/HipChatConfig.js @@ -26,7 +26,7 @@ const HipchatConfig = React.createClass({ const properties = { room: this.room.value, - url: this.url.value, + url: `https://${this.url.value}.hipchat.com/v2/room`, token: this.token.value, } @@ -37,6 +37,8 @@ const HipchatConfig = React.createClass({ const {options} = this.props.config const {url, room, token} = options + const subdomain = url.replace('https://', '').replace('.hipchat.com/v2/room', '') + return (

HipChat Alert

@@ -44,14 +46,14 @@ const HipchatConfig = React.createClass({

Send alert messages to HipChat.

- + this.url = r} - defaultValue={url || ''} + defaultValue={subdomain && subdomain.length ? subdomain : ''} />
@@ -61,7 +63,7 @@ const HipchatConfig = React.createClass({ className="form-control" id="room" type="text" - placeholder="your-hipchat-token" + placeholder="your-hipchat-room" ref={(r) => this.room = r} defaultValue={room || ''} /> diff --git a/ui/src/kapacitor/constants/index.js b/ui/src/kapacitor/constants/index.js index 8e911f0fc..daeb82f28 100644 --- a/ui/src/kapacitor/constants/index.js +++ b/ui/src/kapacitor/constants/index.js @@ -47,6 +47,7 @@ export const DEFAULT_ALERT_LABELS = { tcp: 'Address:', exec: 'Add Command (Arguments separated by Spaces):', smtp: 'Email Addresses (Separated by Spaces):', + slack: 'Send alerts to Slack channel:', alerta: 'Paste Alerta TICKscript:', } export const DEFAULT_ALERT_PLACEHOLDERS = { @@ -54,6 +55,7 @@ export const DEFAULT_ALERT_PLACEHOLDERS = { tcp: 'Address:', exec: 'Ex: woogie boogie', smtp: 'Ex: benedict@domain.com delaney@domain.com susan@domain.com', + slack: '#alerts', alerta: 'alerta()', } @@ -62,6 +64,7 @@ export const ALERT_NODES_ACCESSORS = { tcp: (rule) => _.get(rule, 'alertNodes[0].args[0]', ''), exec: (rule) => _.get(rule, 'alertNodes[0].args', []).join(' '), smtp: (rule) => _.get(rule, 'alertNodes[0].args', []).join(' '), + slack: (rule) => _.get(rule, 'alertNodes[0].properties[0].args', ''), alerta: (rule) => _.get(rule, 'alertNodes[0].properties', []).reduce((strs, item) => { strs.push(`${item.name}('${item.args.join(' ')}')`) return strs diff --git a/ui/src/kapacitor/reducers/rules.js b/ui/src/kapacitor/reducers/rules.js index 57f720492..13245efc2 100644 --- a/ui/src/kapacitor/reducers/rules.js +++ b/ui/src/kapacitor/reducers/rules.js @@ -101,6 +101,21 @@ export default function rules(state = {}, action) { }, ] break + case 'slack': + alertNodesByType = [ + { + name: alertType, + properties: [ + { + name: 'channel', + args: [ + alertNodesText, + ], + }, + ], + }, + ] + break case 'alerta': alertNodesByType = [ { diff --git a/ui/src/shared/components/LayoutRenderer.js b/ui/src/shared/components/LayoutRenderer.js index c8992cb4d..fc6b3cd9f 100644 --- a/ui/src/shared/components/LayoutRenderer.js +++ b/ui/src/shared/components/LayoutRenderer.js @@ -100,7 +100,8 @@ export const LayoutRenderer = React.createClass({ // on a stable query representation. let queryText if (query.queryConfig) { - queryText = buildInfluxQLQuery(timeRange, query.queryConfig) + const {queryConfig: {rawText}} = query + queryText = rawText || buildInfluxQLQuery(timeRange, query.queryConfig) } else { queryText = this.buildQueryForOldQuerySchema(query) } @@ -187,7 +188,7 @@ export const LayoutRenderer = React.createClass({ useCSSTransforms={false} onResize={this.triggerWindowResize} onLayoutChange={this.handleLayoutChange} - draggableHandle={'.dash-graph--drag-handle'} + draggableHandle={'.dash-graph--name'} isDraggable={isDashboard} isResizable={isDashboard} > diff --git a/ui/src/shared/components/NameableGraph.js b/ui/src/shared/components/NameableGraph.js index f56960f94..b73538ff4 100644 --- a/ui/src/shared/components/NameableGraph.js +++ b/ui/src/shared/components/NameableGraph.js @@ -87,36 +87,34 @@ const NameableGraph = React.createClass({ /> ) } else { - nameOrField = ({name}) + nameOrField = ({name}) } - let onClickHandler + let onStartRenaming if (!isEditing && isEditable) { - onClickHandler = onEditCell + onStartRenaming = onEditCell } else { - onClickHandler = () => { + onStartRenaming = () => { // no-op } } return (
-
-
{nameOrField}
- {shouldNotBeEditable ? null :
} - { - shouldNotBeEditable ? - null : - - } -
+
{nameOrField}
+ { + shouldNotBeEditable ? + null : + + }
{children}
@@ -125,13 +123,14 @@ const NameableGraph = React.createClass({ }, }) -const ContextMenu = OnClickOutside(({isOpen, toggleMenu, onEdit, onDelete, cell}) => ( +const ContextMenu = OnClickOutside(({isOpen, toggleMenu, onEdit, onRename, onDelete, cell}) => (
  • onEdit(cell)}>Edit
  • +
  • Rename
  • onDelete(cell)}>Delete
diff --git a/ui/src/shared/constants/index.js b/ui/src/shared/constants/index.js index 7ae12f866..4298826bf 100644 --- a/ui/src/shared/constants/index.js +++ b/ui/src/shared/constants/index.js @@ -468,6 +468,8 @@ export const STROKE_WIDTH = { light: 1.5, } +export const HEARTBEAT_INTERVAL = 10000 // ms + export const PRESENTATION_MODE_ANIMATION_DELAY = 0 // In milliseconds. export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds. @@ -475,6 +477,7 @@ export const SHORT_NOTIFICATION_DISMISS_DELAY = 1500 // in milliseconds export const REVERT_STATE_DELAY = 1500 // ms -export const RES_UNAUTHORIZED = 401 +export const HTTP_UNAUTHORIZED = 401 +export const HTTP_FORBIDDEN = 403 export const AUTOREFRESH_DEFAULT = 15000 // in milliseconds diff --git a/ui/src/side_nav/components/SideNav.js b/ui/src/side_nav/components/SideNav.js index 29f9bd7c5..ac899f87e 100644 --- a/ui/src/side_nav/components/SideNav.js +++ b/ui/src/side_nav/components/SideNav.js @@ -11,7 +11,7 @@ const SideNav = React.createClass({ location: string.isRequired, sourceID: string.isRequired, me: shape({ - email: string, + name: string, }), isHidden: bool.isRequired, }, @@ -21,7 +21,7 @@ const SideNav = React.createClass({ const sourcePrefix = `/sources/${sourceID}` const dataExplorerLink = `${sourcePrefix}/chronograf/data-explorer` - const loggedIn = !!(me && me.email) + const loggedIn = !!(me && me.name) return isHidden ? null : ( diff --git a/ui/src/style/pages/dashboards.scss b/ui/src/style/pages/dashboards.scss index e91ca185c..ae500071e 100644 --- a/ui/src/style/pages/dashboards.scss +++ b/ui/src/style/pages/dashboards.scss @@ -3,7 +3,8 @@ ------------------------------------------------------ */ $dash-graph-heading: 30px; - +$dash-graph-heading-context: $dash-graph-heading - 8px; +$dash-graph-options-arrow: 8px; /* Animations @@ -56,6 +57,7 @@ $dash-graph-heading: 30px; left: 0; } .dash-graph--container { + z-index: 0; user-select: none !important; -o-user-select: none !important; -moz-user-select: none !important; @@ -87,7 +89,7 @@ $dash-graph-heading: 30px; } } .dash-graph--heading { - z-index: 1; + z-index: 0; user-select: none !important; -o-user-select: none !important; -moz-user-select: none !important; @@ -98,112 +100,64 @@ $dash-graph-heading: 30px; left: 0; width: 100%; height: $dash-graph-heading; - padding: 0 16px; margin: 0; display: flex; align-items: center; justify-content: space-between; - border-radius: $radius; + border-radius: $radius-small; font-weight: 600; font-size: 13px; color: $g14-chromium; transition: color 0.25s ease, background-color 0.25s ease; - &:hover { - cursor: default; - } -} -.dash-graph--drag-handle { - height: $dash-graph-heading; - width: 100%; - - &:before { - content: ''; - position: absolute; - width: 100%; - left: 0; - height: $dash-graph-heading; - background-color: $g5-pepper; - border-radius: 3px; - z-index: -1; - opacity: 0; - transition: opacity 0.25s ease; - } - &:hover { + &.dash-graph--heading-draggable:hover { cursor: move; - } - &:hover:before { - opacity: 1; + background-color: $g5-pepper; } } .dash-graph--name-edit, .dash-graph--name { - display: inline-block; - padding: 0 6px; - height: 26px !important; - line-height: (26px - 4px) !important; font-size: 13px; font-weight: 600; - position: relative; - left: -6px; border-radius: $radius; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dash-graph--name { + height: $dash-graph-heading; + line-height: $dash-graph-heading; + width: calc(100% - 50px); + margin-left: 16px; transition: color 0.25s ease, background-color 0.25s ease, border-color 0.25s ease; - border: 2px solid transparent; - top: 2px; - - &.editable { - &:after { - display: inline-block; - content: "\f058"; - font-family: 'icomoon' !important; - font-style: normal; - font-weight: normal; - font-variant: normal; - line-height: 1; - text-transform: none; - color: $g11-sidewalk; - font-size: 14px; - opacity: 0; - transition: opacity 0.25s ease; - margin-left: 5px; - } - - &:hover { - cursor: text; - color: $g20-white; - - &:after { - opacity: 1; - } - } - } } .dash-graph--name-edit { - top: -1px; - width: 190px; + margin-left: 8px; + padding: 0 6px; + width: calc(100% - 42px); + height: 26px !important; + line-height: (26px - 4px) !important; + position: relative; + top: -1px; // Fix for slight offset } .dash-graph--options { - height: $dash-graph-heading; - position: relative; - width: ($dash-graph-heading - 8px); - right: -6px; + width: $dash-graph-heading; + position: absolute; + z-index: 1; + right: 0px; + top: 0px; > .btn { background-color: transparent !important; padding: 0; margin: 4px 0; - height: ($dash-graph-heading - 8px); - width: ($dash-graph-heading - 8px); - line-height: ($dash-graph-heading - 8px); + height: $dash-graph-heading-context; + width: $dash-graph-heading-context; + line-height: $dash-graph-heading-context; transition: background-color 0.25s ease, color 0.25s ease !important; @@ -214,7 +168,6 @@ $dash-graph-heading: 30px; } } } -$dash-graph-options-arrow: 8px; .presentation-mode .dash-graph--options { display: none; @@ -351,9 +304,8 @@ $dash-graph-options-arrow: 8px; cursor: move; } - .dash-graph--drag-handle:before, - .dash-graph--drag-handle:hover:before { - opacity: 1 !important; + .dash-graph--heading { + background-color: $g5-pepper; cursor: move; } } diff --git a/ui/src/utils/ajax.js b/ui/src/utils/ajax.js index 9695c1b4f..59c3fa6d4 100644 --- a/ui/src/utils/ajax.js +++ b/ui/src/utils/ajax.js @@ -2,7 +2,7 @@ import axios from 'axios' let links -import {RES_UNAUTHORIZED} from 'shared/constants' +import {HTTP_UNAUTHORIZED} from 'shared/constants' export default async function AJAX({ url, @@ -47,7 +47,7 @@ export default async function AJAX({ } } catch (error) { const {response} = error - if (!response.status === RES_UNAUTHORIZED) { + if (!response.status === HTTP_UNAUTHORIZED) { console.error(error) // eslint-disable-line no-console } // console.error(error) // eslint-disable-line no-console