Merge branch 'master' into feature/meta-query-builder

pull/1212/head
Andrew Watkins 2017-04-07 10:50:03 -06:00 committed by GitHub
commit 5b61ff5a11
43 changed files with 1001 additions and 629 deletions

View File

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

View File

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

View File

@ -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").

View File

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

View File

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

View File

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

42
influx/query_test.go Normal file
View File

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

View File

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

View File

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

81
oauth2/cookies.go Normal file
View File

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

152
oauth2/cookies_test.go Normal file
View File

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

View File

@ -8,21 +8,22 @@
// ├────────────────────────────────────────┴────────────────────────────────────┐
// │┌────────────────────┐ │
// ││ <<interface>> │ ┌─────────────────────────┐ │
// ││ 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 │ │ ┌───────────────┐ │
// │├───────────────────────┤ ▼ │ <<interface>> │ │
// ││+Secret : string │ ┌───────────────┐ │ OAuth2Mux │ │
// ││+Now : func() time.Time│ │ <<interface>> │ ├───────────────┤ │
// ││Create() │ ┌───────────────┐ │ OAuth2Mux │ │
// ││ValidPrincipal() │ │ <<interface>> │ ├───────────────┤ │
// │└───────────────────────┘ │ Provider │ │Login() │ │
// │ ├───────────────┤ │Logout() │ │
// │ │ID() │ │Callback() │ │

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

38
server/auth.go Normal file
View File

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

85
server/auth_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

30
server/routes_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<div>
<h4 className="text-center no-user-select">HipChat Alert</h4>
@ -44,14 +46,14 @@ const HipchatConfig = React.createClass({
<p className="no-user-select">Send alert messages to HipChat.</p>
<form onSubmit={this.handleSaveAlert}>
<div className="form-group col-xs-12">
<label htmlFor="url">HipChat URL</label>
<label htmlFor="url">Subdomain</label>
<input
className="form-control"
id="url"
type="text"
placeholder="https://your-subdomain.hipchat.com/v2/room"
placeholder="your-subdomain"
ref={(r) => this.url = r}
defaultValue={url || ''}
defaultValue={subdomain && subdomain.length ? subdomain : ''}
/>
</div>
@ -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 || ''}
/>

View File

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

View File

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

View File

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

View File

@ -87,36 +87,34 @@ const NameableGraph = React.createClass({
/>
)
} else {
nameOrField = (<span className={classnames("dash-graph--name", {editable: !shouldNotBeEditable})}>{name}</span>)
nameOrField = (<span className="dash-graph--name">{name}</span>)
}
let onClickHandler
let onStartRenaming
if (!isEditing && isEditable) {
onClickHandler = onEditCell
onStartRenaming = onEditCell
} else {
onClickHandler = () => {
onStartRenaming = () => {
// no-op
}
}
return (
<div className="dash-graph">
<div className="dash-graph--heading">
<div onClick={onClickHandler(x, y, isEditing)}>{nameOrField}</div>
{shouldNotBeEditable ? null : <div className="dash-graph--drag-handle"></div>}
{
shouldNotBeEditable ?
null :
<ContextMenu
isOpen={this.state.isMenuOpen}
toggleMenu={this.toggleMenu}
onEdit={onSummonOverlayTechnologies}
onDelete={onDeleteCell}
cell={cell}
handleClickOutside={this.closeMenu}
/>
}
</div>
<div className={classnames("dash-graph--heading", {"dash-graph--heading-draggable": !shouldNotBeEditable})}>{nameOrField}</div>
{
shouldNotBeEditable ?
null :
<ContextMenu
isOpen={this.state.isMenuOpen}
toggleMenu={this.toggleMenu}
onEdit={onSummonOverlayTechnologies}
onRename={onStartRenaming}
onDelete={onDeleteCell}
cell={cell}
handleClickOutside={this.closeMenu}
/>
}
<div className="dash-graph--container">
{children}
</div>
@ -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}) => (
<div className={classnames("dash-graph--options", {"dash-graph--options-show": isOpen})} onClick={toggleMenu}>
<button className="btn btn-info btn-xs">
<span className="icon caret-down"></span>
</button>
<ul className="dash-graph--options-menu">
<li onClick={() => onEdit(cell)}>Edit</li>
<li onClick={onRename(cell.x, cell.y, cell.isEditing)}>Rename</li>
<li onClick={() => onDelete(cell)}>Delete</li>
</ul>
</div>

View File

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

View File

@ -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 : (
<NavBar location={location}>

View File

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

View File

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