Merge branch 'master' into hotfix/1193-influxql-no-quotes
commit
d6fab042ed
|
@ -18,12 +18,18 @@
|
|||
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.
|
||||
|
||||
### 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
|
||||
|
||||
### UI Improvements
|
||||
|
@ -35,6 +41,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
|
||||
|
|
16
Makefile
16
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:
|
||||
|
|
44
docs/auth.md
44
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").
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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() │ │
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
112
oauth2/jwt.go
112
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
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
108
oauth2/mux.go
108
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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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" {
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 || ''}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
@ -188,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}
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -3,7 +3,8 @@
|
|||
------------------------------------------------------
|
||||
*/
|
||||
$dash-graph-heading: 30px;
|
||||
|
||||
$dash-graph-heading-context: $dash-graph-heading - 8px;
|
||||
$dash-graph-options-arrow: 8px;
|
||||
|
||||
/*
|
||||
Animations
|
||||
|
@ -98,112 +99,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: 2;
|
||||
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 +167,6 @@ $dash-graph-heading: 30px;
|
|||
}
|
||||
}
|
||||
}
|
||||
$dash-graph-options-arrow: 8px;
|
||||
|
||||
.presentation-mode .dash-graph--options {
|
||||
display: none;
|
||||
|
@ -351,9 +303,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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue