Merge pull request #922 from influxdata/feature/oauth-google
Support Multiple OAuth2 Providers - Add Heroku and Googlepull/931/head
commit
2ee0008f42
|
@ -10,6 +10,7 @@
|
|||
3. [#891](https://github.com/influxdata/chronograf/issues/891): Make dashboard visualizations draggable
|
||||
4. [#892](https://github.com/influxdata/chronograf/issues/891): Make dashboard visualizations resizable
|
||||
5. [#893](https://github.com/influxdata/chronograf/issues/893): Persist dashboard visualization position
|
||||
6. [#922](https://github.com/influxdata/chronograf/issues/922): Additional OAuth2 support for Heroku and Google
|
||||
|
||||
### UI Improvements
|
||||
1. [#905](https://github.com/influxdata/chronograf/pull/905): Make scroll bar thumb element bigger
|
||||
|
|
1
Godeps
1
Godeps
|
@ -15,3 +15,4 @@ github.com/sergi/go-diff 1d28411638c1e67fe1930830df207bef72496ae9
|
|||
github.com/tylerb/graceful 50a48b6e73fcc75b45e22c05b79629a67c79e938
|
||||
golang.org/x/net 749a502dd1eaf3e5bfd4f8956748c502357c0bbe
|
||||
golang.org/x/oauth2 1e695b1c8febf17aad3bfa7bf0a819ef94b98ad5
|
||||
google.golang.org/api bc20c61134e1d25265dd60049f5735381e79b631
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
* github.com/tylerb/graceful [MIT](https://github.com/tylerb/graceful/blob/master/LICENSE)
|
||||
* golang.org/x/net [BSD](https://github.com/golang/net/blob/master/LICENSE)
|
||||
* golang.org/x/oauth2 [BSD](https://github.com/golang/oauth2/blob/master/LICENSE)
|
||||
* google.golang.org/api/oauth2/v2 [BSD](https://github.com/google/google-api-go-client/blob/master/LICENSE)
|
||||
|
||||
### Javascript
|
||||
* Base64 0.2.1 [WTFPL](http://github.com/davidchambers/Base64.js)
|
||||
|
|
5
Makefile
5
Makefile
|
@ -1,4 +1,4 @@
|
|||
.PHONY: assets dep clean test gotest gotestrace jstest run run-dev
|
||||
.PHONY: assets dep clean test gotest gotestrace jstest run run-dev ctags
|
||||
|
||||
VERSION ?= $(shell git describe --always --tags)
|
||||
COMMIT ?= $(shell git rev-parse --short=8 HEAD)
|
||||
|
@ -102,3 +102,6 @@ clean:
|
|||
cd ui && rm -rf node_modules
|
||||
rm -f dist/dist_gen.go canned/bin_gen.go server/swagger_gen.go
|
||||
@rm -f .godep .jsdep .jssrc .dev-jssrc .bindata
|
||||
|
||||
ctags:
|
||||
ctags -R --languages="Go" --exclude=.git --exclude=ui .
|
||||
|
|
|
@ -110,7 +110,7 @@ A UI for [Kapacitor](https://github.com/influxdata/kapacitor) alert creation and
|
|||
### TLS/HTTPS support
|
||||
See [Chronograf with TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) for more information.
|
||||
|
||||
### GitHub OAuth Login
|
||||
### OAuth Login
|
||||
See [Chronograf with OAuth 2.0](https://github.com/influxdata/chronograf/blob/master/docs/auth.md) for more information.
|
||||
|
||||
### Advanced Routing
|
||||
|
|
|
@ -3,7 +3,6 @@ package chronograf
|
|||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// General errors.
|
||||
|
@ -16,7 +15,6 @@ const (
|
|||
ErrUserNotFound = Error("user not found")
|
||||
ErrLayoutInvalid = Error("layout is invalid")
|
||||
ErrAlertNotFound = Error("alert not found")
|
||||
ErrAuthentication = Error("user not authenticated")
|
||||
)
|
||||
|
||||
// Error is a domain error encountered while processing chronograf requests
|
||||
|
@ -310,25 +308,3 @@ type LayoutStore interface {
|
|||
// Update the dashboard in the store.
|
||||
Update(context.Context, Layout) error
|
||||
}
|
||||
|
||||
// Principal is any entity that can be authenticated
|
||||
type Principal string
|
||||
|
||||
// PrincipalKey is used to pass principal
|
||||
// via context.Context to request-scoped
|
||||
// functions.
|
||||
const PrincipalKey Principal = "principal"
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// TokenExtractor extracts tokens from http requests
|
||||
type TokenExtractor interface {
|
||||
// Extract will return the token or an error.
|
||||
Extract(r *http.Request) (string, error)
|
||||
}
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 59 KiB |
120
docs/auth.md
120
docs/auth.md
|
@ -7,6 +7,18 @@ OAuth 2.0 Style Authentication
|
|||
|
||||
To use authentication in Chronograf, both Github OAuth 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.
|
||||
|
||||
```sh
|
||||
export TOKEN_SECRET=supersupersecret
|
||||
```
|
||||
|
||||
# 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.
|
||||
|
@ -29,18 +41,6 @@ export GH_CLIENT_ID=b339dd4fddd95abec9aa
|
|||
export GH_CLIENT_SECRET=260041897d3252c146ece6b46ba39bc1e54416dc
|
||||
```
|
||||
|
||||
#### Configuring JWT signature
|
||||
|
||||
Set a [JWT](https://tools.ietf.org/html/rfc7519) signature to a random string.
|
||||
*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.
|
||||
|
||||
```sh
|
||||
export TOKEN_SECRET=supersupersecret
|
||||
```
|
||||
|
||||
#### Optional Github Organizations
|
||||
|
||||
To require an organization membership for a user, set the `GH_ORGS` environment variables
|
||||
|
@ -56,72 +56,50 @@ To support multiple organizations use a comma delimted list like so:
|
|||
export GH_ORGS=hill-valley-preservation-sociey,the-pinheads
|
||||
```
|
||||
|
||||
### Design
|
||||
# Google
|
||||
|
||||
The Chronograf authentication scheme is a standard [web application](https://developer.github.com/v3/oauth/#web-application-flow) OAuth flow.
|
||||
#### Creating Google OAuth Application
|
||||
|
||||
![oauth 2.0 flow](./OauthStyleAuthentication.png)
|
||||
You will need to obtain a client ID and an application secret by following the steps under "Basic Steps" [here](https://developers.google.com/identity/protocols/OAuth2). Chronograf will also need to be publicly accessible via a fully qualified domain name so that Google properly redirects users back to the application.
|
||||
|
||||
The browser receives a cookie from Chronograf, authorizing it. The contents of the cookie is a JWT whose "sub" claim is the user's primary
|
||||
github email address.
|
||||
This information should be set in the following ENVs:
|
||||
|
||||
On each request to Chronograf, the JWT contained in the cookie will be validated against the `TOKEN_SECRET` signature and checked for expiration.
|
||||
The JWT's "sub" becomes the [principal](https://en.wikipedia.org/wiki/Principal_(computer_security)) used for authorization to resources.
|
||||
* `GOOGLE_CLIENT_ID`
|
||||
* `GOOGLE_CLIENT_SECRET`
|
||||
* `PUBLIC_URL`
|
||||
|
||||
The API provides three endpoints `/oauth`, `/oauth/logout` and `/oauth/github/callback`.
|
||||
Alternatively, this can also be set using the command line switches:
|
||||
|
||||
#### /oauth
|
||||
* `--google-client-id`
|
||||
* `--google-client-secret`
|
||||
* `--public-url`
|
||||
|
||||
The `/oauth` endpoint redirects to Github for OAuth. Chronograf sets the OAuth `state` request parameter to a JWT with a random "sub". Using $TOKEN_SECRET `/oauth/github/callback`
|
||||
can validate the `state` parameter without needing `state` to be saved.
|
||||
#### Optional Google Domains
|
||||
|
||||
#### /oauth/github/callback
|
||||
Similar to Github's organization restriction, Google authentication can be restricted to permit access to Chronograf from only specific domains. These are configured using the `GOOGLE_DOMAINS` ENV or the `--google-domains` switch. Multiple domains are separated with a comma. For example, if we wanted to permit access only from biffspleasurepalace.com and savetheclocktower.com the ENV would be set as follows:
|
||||
|
||||
The `/oauth/github/callback` receives the OAuth `authorization code` and `state`.
|
||||
|
||||
First, it will validate the `state` JWT from the `/oauth` endpoint. `JWT` validation
|
||||
only requires access to the signature token. Therefore, there is no need for `state`
|
||||
to be saved. Additionally, multiple Chronograf servers will not need to share third
|
||||
party storage to synchronize `state`. If this validation fails, the request
|
||||
will be redirected to `/login`.
|
||||
|
||||
Secondly, the endpoint will use the `authorization code` to retrieve a valid OAuth token
|
||||
with the `user:email` scope. If unable to get a token from Github, the request will
|
||||
be redirected to `/login`.
|
||||
|
||||
Finally, the endpoint will attempt to get the primary email address of the Github user.
|
||||
Again, if not successful, the request will redirect to `/login`.
|
||||
|
||||
The email address is used as the subject claim for a new JWT. This JWT becomes the
|
||||
value of the cookie sent back to the browser. The cookie is valid for thirty days.
|
||||
|
||||
Next, the request is redirected to `/`.
|
||||
|
||||
For all API calls to `/chronograf/v1`, the server checks for the existence and validity
|
||||
of the JWT within the cookie value.
|
||||
If the request did not have a valid JWT, the API returns `HTTP/1.1 401 Unauthorized`.
|
||||
|
||||
#### /oauth/logout
|
||||
|
||||
Simply expires the session cookie and redirects to `/`.
|
||||
|
||||
### Authorization
|
||||
|
||||
After successful validation of the JWT, each API endpoint of `/chronograf/v1` receives the
|
||||
JWT subject within the `http.Request` as a `context.Context` value.
|
||||
|
||||
Within the Go API code all interfaces take `context.Context`. This means that each
|
||||
interface can use the value as a principal. The design allows for authorization to happen
|
||||
at the level of design most closely related to the problem.
|
||||
|
||||
An example usage in Go would be:
|
||||
|
||||
```go
|
||||
func ShallIPass(ctx context.Context) (string, error) {
|
||||
principal := ctx.Value(chronograf.PrincipalKey).(chronograf.Principal)
|
||||
if principal != "gandolf@moria.misty.mt" {
|
||||
return "you shall not pass", chronograf.ErrAuthentication
|
||||
}
|
||||
return "run you fools", nil
|
||||
}
|
||||
```sh
|
||||
export GOOGLE_DOMAINS=biffspleasurepalance.com,savetheclocktower.com
|
||||
```
|
||||
|
||||
# Heroku
|
||||
|
||||
#### Creating Heroku Application
|
||||
|
||||
To obtain a client ID and application secret for Heroku, you will need to follow the guide posted [here](https://devcenter.heroku.com/articles/oauth#register-client). Once your application has been created, those two values should be inserted into the following ENVs:
|
||||
|
||||
* `HEROKU_CLIENT_ID`
|
||||
* `HEROKU_SECRET`
|
||||
|
||||
The equivalent command line switches are:
|
||||
|
||||
* `--heroku-client-id`
|
||||
* `--heroku-secret`
|
||||
|
||||
#### Optional Heroku Organizations
|
||||
|
||||
Like the other OAuth2 providers, access to Chronograf via Heroku can be restricted to members of specific Heroku organizations. This is controlled using the `HEROKU_ORGS` ENV or the `--heroku-organizations` switch and is comma-separated. If we wanted to permit access from the `hill-valley-preservation-society` orgization and `the-pinheads` organization, we would use the following ENV:
|
||||
|
||||
```sh
|
||||
export HEROKU_ORGS=hill-valley-preservation-sociey,the-pinheads
|
||||
```
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package server
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
@ -17,7 +17,7 @@ type CookieExtractor struct {
|
|||
func (c *CookieExtractor) Extract(r *http.Request) (string, error) {
|
||||
cookie, err := r.Cookie(c.Name)
|
||||
if err != nil {
|
||||
return "", chronograf.ErrAuthentication
|
||||
return "", ErrAuthentication
|
||||
}
|
||||
return cookie.Value, nil
|
||||
}
|
||||
|
@ -29,14 +29,14 @@ type BearerExtractor struct{}
|
|||
func (b *BearerExtractor) Extract(r *http.Request) (string, error) {
|
||||
s := r.Header.Get("Authorization")
|
||||
if s == "" {
|
||||
return "", chronograf.ErrAuthentication
|
||||
return "", ErrAuthentication
|
||||
}
|
||||
|
||||
// Check for Bearer token.
|
||||
strs := strings.Split(s, " ")
|
||||
|
||||
if len(strs) != 2 || strs[0] != "Bearer" {
|
||||
return "", chronograf.ErrAuthentication
|
||||
return "", ErrAuthentication
|
||||
}
|
||||
return strs[1], nil
|
||||
}
|
||||
|
@ -45,7 +45,7 @@ func (b *BearerExtractor) Extract(r *http.Request) (string, error) {
|
|||
// 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 chronograf.Authenticator, te chronograf.TokenExtractor, logger chronograf.Logger, next http.Handler) http.HandlerFunc {
|
||||
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").
|
||||
|
@ -55,12 +55,13 @@ func AuthorizedToken(auth chronograf.Authenticator, te chronograf.TokenExtractor
|
|||
|
||||
token, err := te.Extract(r)
|
||||
if err != nil {
|
||||
log.Error("Unable to extract token")
|
||||
// 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
|
||||
// server further down the chain should do so.
|
||||
// served further down the chain should do so.
|
||||
principal, err := auth.Authenticate(r.Context(), token)
|
||||
if err != nil {
|
||||
log.Error("Invalid token")
|
||||
|
@ -69,7 +70,7 @@ func AuthorizedToken(auth chronograf.Authenticator, te chronograf.TokenExtractor
|
|||
}
|
||||
|
||||
// Send the principal to the next handler
|
||||
ctx := context.WithValue(r.Context(), chronograf.PrincipalKey, principal)
|
||||
ctx := context.WithValue(r.Context(), PrincipalKey, principal)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
})
|
|
@ -1,4 +1,4 @@
|
|||
package server_test
|
||||
package oauth2_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
@ -8,9 +8,8 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
clog "github.com/influxdata/chronograf/log"
|
||||
"github.com/influxdata/chronograf/server"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
func TestCookieExtractor(t *testing.T) {
|
||||
|
@ -28,7 +27,7 @@ func TestCookieExtractor(t *testing.T) {
|
|||
Value: "reallyimportant",
|
||||
Lookup: "Doesntexist",
|
||||
Expected: "",
|
||||
Err: chronograf.ErrAuthentication,
|
||||
Err: oauth2.ErrAuthentication,
|
||||
},
|
||||
{
|
||||
Desc: "Cookie token extracted",
|
||||
|
@ -46,7 +45,7 @@ func TestCookieExtractor(t *testing.T) {
|
|||
Value: test.Value,
|
||||
})
|
||||
|
||||
var e chronograf.TokenExtractor = &server.CookieExtractor{
|
||||
var e oauth2.TokenExtractor = &oauth2.CookieExtractor{
|
||||
Name: test.Lookup,
|
||||
}
|
||||
actual, err := e.Extract(req)
|
||||
|
@ -74,21 +73,21 @@ func TestBearerExtractor(t *testing.T) {
|
|||
Header: "Doesntexist",
|
||||
Value: "reallyimportant",
|
||||
Expected: "",
|
||||
Err: chronograf.ErrAuthentication,
|
||||
Err: oauth2.ErrAuthentication,
|
||||
},
|
||||
{
|
||||
Desc: "Auth header doesn't have Bearer",
|
||||
Header: "Authorization",
|
||||
Value: "Bad Value",
|
||||
Expected: "",
|
||||
Err: chronograf.ErrAuthentication,
|
||||
Err: oauth2.ErrAuthentication,
|
||||
},
|
||||
{
|
||||
Desc: "Auth header doesn't have Bearer token",
|
||||
Header: "Authorization",
|
||||
Value: "Bearer",
|
||||
Expected: "",
|
||||
Err: chronograf.ErrAuthentication,
|
||||
Err: oauth2.ErrAuthentication,
|
||||
},
|
||||
{
|
||||
Desc: "Authorization Bearer token success",
|
||||
|
@ -102,7 +101,7 @@ func TestBearerExtractor(t *testing.T) {
|
|||
req, _ := http.NewRequest("", "http://howdy.com", nil)
|
||||
req.Header.Add(test.Header, test.Value)
|
||||
|
||||
var e chronograf.TokenExtractor = &server.BearerExtractor{}
|
||||
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)
|
||||
|
@ -123,15 +122,15 @@ func (m *MockExtractor) Extract(*http.Request) (string, error) {
|
|||
}
|
||||
|
||||
type MockAuthenticator struct {
|
||||
Principal chronograf.Principal
|
||||
Principal oauth2.Principal
|
||||
Err error
|
||||
}
|
||||
|
||||
func (m *MockAuthenticator) Authenticate(context.Context, string) (chronograf.Principal, error) {
|
||||
func (m *MockAuthenticator) Authenticate(context.Context, string) (oauth2.Principal, error) {
|
||||
return m.Principal, m.Err
|
||||
}
|
||||
|
||||
func (m *MockAuthenticator) Token(context.Context, chronograf.Principal, time.Duration) (string, error) {
|
||||
func (m *MockAuthenticator) Token(context.Context, oauth2.Principal, time.Duration) (string, error) {
|
||||
return "", m.Err
|
||||
}
|
||||
|
||||
|
@ -139,7 +138,7 @@ func TestAuthorizedToken(t *testing.T) {
|
|||
var tests = []struct {
|
||||
Desc string
|
||||
Code int
|
||||
Principal chronograf.Principal
|
||||
Principal oauth2.Principal
|
||||
ExtractorErr error
|
||||
AuthErr error
|
||||
Expected string
|
||||
|
@ -155,19 +154,21 @@ func TestAuthorizedToken(t *testing.T) {
|
|||
AuthErr: errors.New("error"),
|
||||
},
|
||||
{
|
||||
Desc: "Authorized ok",
|
||||
Code: http.StatusOK,
|
||||
Principal: "Principal Strickland",
|
||||
Expected: "Principal Strickland",
|
||||
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 chronograf.Principal
|
||||
var principal oauth2.Principal
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
principal = r.Context().Value(chronograf.PrincipalKey).(chronograf.Principal)
|
||||
principal = r.Context().Value(oauth2.PrincipalKey).(oauth2.Principal)
|
||||
})
|
||||
req, _ := http.NewRequest("GET", "", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
@ -181,7 +182,7 @@ func TestAuthorizedToken(t *testing.T) {
|
|||
}
|
||||
|
||||
logger := clog.New(clog.DebugLevel)
|
||||
handler := server.AuthorizedToken(a, e, logger, next)
|
||||
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)
|
|
@ -0,0 +1,140 @@
|
|||
// The oauth2 package provides http.Handlers necessary for implementing Oauth2
|
||||
// authentication with multiple Providers.
|
||||
//
|
||||
// This is how the pieces of this package fit together:
|
||||
//
|
||||
// ┌────────────────────────────────────────┐
|
||||
// │github.com/influxdata/chronograf/oauth2 │
|
||||
// ├────────────────────────────────────────┴────────────────────────────────────┐
|
||||
// │┌────────────────────┐ │
|
||||
// ││ <<interface>> │ ┌─────────────────────────┐ │
|
||||
// ││ Authenticator │ │ CookieMux │ │
|
||||
// │├────────────────────┤ ├─────────────────────────┤ │
|
||||
// ││Authenticate() │ Auth │+SuccessURL : string │ │
|
||||
// ││Token() ◀────────│+FailureURL : string │──────────┐ │
|
||||
// │└──────────△─────────┘ │+Now : func() time.Time │ │ │
|
||||
// │ │ └─────────────────────────┘ │ │
|
||||
// │ │ │ │ │
|
||||
// │ │ │ │ │
|
||||
// │ │ Provider│ │ │
|
||||
// │ │ ┌───┘ │ │
|
||||
// │┌──────────┴────────────┐ │ ▽ │
|
||||
// ││ JWT │ │ ┌───────────────┐ │
|
||||
// │├───────────────────────┤ ▼ │ <<interface>> │ │
|
||||
// ││+Secret : string │ ┌───────────────┐ │ OAuth2Mux │ │
|
||||
// ││+Now : func() time.Time│ │ <<interface>> │ ├───────────────┤ │
|
||||
// │└───────────────────────┘ │ Provider │ │Login() │ │
|
||||
// │ ├───────────────┤ │Logout() │ │
|
||||
// │ │ID() │ │Callback() │ │
|
||||
// │ │Scopes() │ └───────────────┘ │
|
||||
// │ │Secret() │ │
|
||||
// │ │Authenticator()│ │
|
||||
// │ └───────────────┘ │
|
||||
// │ △ │
|
||||
// │ │ │
|
||||
// │ ┌─────────────────────────┼─────────────────────────┐ │
|
||||
// │ │ │ │ │
|
||||
// │ │ │ │ │
|
||||
// │ │ │ │ │
|
||||
// │ ┌───────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐│
|
||||
// │ │ Github │ │ Google │ │ Heroku ││
|
||||
// │ ├───────────────────────┤ ├──────────────────────┤ ├──────────────────────┤│
|
||||
// │ │+ClientID : string │ │+ClientID : string │ │+ClientID : string ││
|
||||
// │ │+ClientSecret : string │ │+ClientSecret : string│ │+ClientSecret : string││
|
||||
// │ │+Orgs : []string │ │+Domains : []string │ └──────────────────────┘│
|
||||
// │ └───────────────────────┘ │+RedirectURL : string │ │
|
||||
// │ └──────────────────────┘ │
|
||||
// └─────────────────────────────────────────────────────────────────────────────┘
|
||||
//
|
||||
// The design focuses on an Authenticator, a Provider, and an OAuth2Mux. Their
|
||||
// responsibilities, respectively, are to decode and encode secrets received
|
||||
// from a Provider, to perform Provider specific operations in order to extract
|
||||
// information about a user, and to produce the handlers which persist secrets.
|
||||
// To add a new provider, You need only implement the Provider interface, and
|
||||
// add its endpoints to the server Mux.
|
||||
//
|
||||
// The Oauth2 flow between a browser, backend, and a Provider that this package
|
||||
// implements is pictured below for reference.
|
||||
//
|
||||
// ┌─────────┐ ┌───────────┐ ┌────────┐
|
||||
// │ Browser │ │Chronograf │ │Provider│
|
||||
// └─────────┘ └───────────┘ └────────┘
|
||||
// │ │ │
|
||||
// ├─────── GET /auth ─────────▶ │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
// ◀ ─ ─ ─302 to Provider ─ ─ ┤ │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
// ├──────────────── GET /auth w/ callback ─────────────────────▶
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
// ◀─ ─ ─ ─ ─ ─ ─ 302 to Chronograf Callback ─ ─ ─ ─ ─ ─ ─ ─ ┤
|
||||
// │ │ │
|
||||
// │ Code and State from │ │
|
||||
// │ Provider │ │
|
||||
// ├───────────────────────────▶ Request token w/ code & │
|
||||
// │ │ state │
|
||||
// │ ├────────────────────────────────▶
|
||||
// │ │ │
|
||||
// │ │ Response with │
|
||||
// │ │ Token │
|
||||
// │ Set cookie, Redirect │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
|
||||
// │ to / │ │
|
||||
// ◀───────────────────────────┤ │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
//
|
||||
// The browser ultimately receives a cookie from Chronograf, authorizing it.
|
||||
// Its contents are encoded as a JWT whose "sub" claim is the user's email
|
||||
// address for whatever provider they have authenticated with. Each request to
|
||||
// Chronograf will validate the contents of this JWT against the `TOKEN_SECRET`
|
||||
// and checked for expiration. The JWT's "sub" becomes the
|
||||
// https://en.wikipedia.org/wiki/Principal_(computer_security) used for
|
||||
// authorization to resources.
|
||||
//
|
||||
// The Mux is responsible for providing three http.Handlers for servicing the
|
||||
// above interaction. These are mounted at specific endpoints by convention
|
||||
// shared with the front end. Any future Provider routes should follow the same
|
||||
// convention to ensure compatibility with the front end logic. These routes
|
||||
// and their responsibilities are:
|
||||
//
|
||||
// /oauth/{provider}/login
|
||||
//
|
||||
// The `/oauth` endpoint redirects to the Provider for OAuth. Chronograf sets
|
||||
// the OAuth `state` request parameter to a JWT with a random "sub". Using
|
||||
// $TOKEN_SECRET `/oauth/github/callback` can validate the `state` parameter
|
||||
// without needing `state` to be saved.
|
||||
//
|
||||
// /oauth/{provider}/callback
|
||||
//
|
||||
// The `/oauth/github/callback` receives the OAuth `authorization code` and `state`.
|
||||
//
|
||||
// First, it will validate the `state` JWT from the `/oauth` endpoint. `JWT` validation
|
||||
// only requires access to the signature token. Therefore, there is no need for `state`
|
||||
// to be saved. Additionally, multiple Chronograf servers will not need to share third
|
||||
// party storage to synchronize `state`. If this validation fails, the request
|
||||
// will be redirected to `/login`.
|
||||
//
|
||||
// Secondly, the endpoint will use the `authorization code` to retrieve a valid OAuth token
|
||||
// with the `user:email` scope. If unable to get a token from Github, the request will
|
||||
// be redirected to `/login`.
|
||||
//
|
||||
// Finally, the endpoint will attempt to get the primary email address of the
|
||||
// user. Again, if not successful, the request will redirect to `/login`.
|
||||
//
|
||||
// The email address is used as the subject claim for a new JWT. This JWT becomes the
|
||||
// value of the cookie sent back to the browser. The cookie is valid for thirty days.
|
||||
//
|
||||
// Next, the request is redirected to `/`.
|
||||
//
|
||||
// For all API calls to `/chronograf/v1`, the server checks for the existence and validity
|
||||
// of the JWT within the cookie value.
|
||||
// If the request did not have a valid JWT, the API returns `HTTP/1.1 401 Unauthorized`.
|
||||
//
|
||||
// /oauth/{provider}/logout
|
||||
//
|
||||
// Simply expires the session cookie and redirects to `/`.
|
||||
package oauth2
|
|
@ -0,0 +1,162 @@
|
|||
package oauth2
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/go-github/github"
|
||||
"github.com/influxdata/chronograf"
|
||||
"golang.org/x/oauth2"
|
||||
ogh "golang.org/x/oauth2/github"
|
||||
)
|
||||
|
||||
var _ Provider = &Github{}
|
||||
|
||||
// Github provides OAuth Login and Callback server. Callback will set
|
||||
// an authentication cookie. This cookie's value is a JWT containing
|
||||
// the user's primary Github email address.
|
||||
type Github struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Orgs []string // Optional github organization checking
|
||||
Logger chronograf.Logger
|
||||
}
|
||||
|
||||
// Name is the name of the provider
|
||||
func (g *Github) Name() string {
|
||||
return "github"
|
||||
}
|
||||
|
||||
// ID returns the github application client id
|
||||
func (g *Github) ID() string {
|
||||
return g.ClientID
|
||||
}
|
||||
|
||||
// Secret returns the github application client secret
|
||||
func (g *Github) Secret() string {
|
||||
return g.ClientSecret
|
||||
}
|
||||
|
||||
// Scopes for github is only the email addres and possible organizations if
|
||||
// we are filtering by organizations.
|
||||
func (g *Github) Scopes() []string {
|
||||
scopes := []string{"user:email"}
|
||||
if len(g.Orgs) > 0 {
|
||||
scopes = append(scopes, "read:org")
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
// Config is the Github OAuth2 exchange information and endpoints
|
||||
func (g *Github) Config() *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: g.ID(),
|
||||
ClientSecret: g.Secret(),
|
||||
Scopes: g.Scopes(),
|
||||
Endpoint: ogh.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// PrincipalID returns the github email address of the user.
|
||||
func (g *Github) PrincipalID(provider *http.Client) (string, error) {
|
||||
client := github.NewClient(provider)
|
||||
// If we need to restrict to a set of organizations, we first get the org
|
||||
// and filter.
|
||||
if len(g.Orgs) > 0 {
|
||||
orgs, err := getOrganizations(client, g.Logger)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Not a member, so, deny permission
|
||||
if ok := isMember(g.Orgs, orgs); !ok {
|
||||
g.Logger.Error("Not a member of required github organization")
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
email, err := getPrimaryEmail(client, g.Logger)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func randomString(length int) string {
|
||||
k := make([]byte, length)
|
||||
if _, err := io.ReadFull(rand.Reader, k); err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k)
|
||||
}
|
||||
|
||||
func logResponseError(log chronograf.Logger, resp *github.Response, err error) {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
log.Error("OAuth access to email address forbidden ", err.Error())
|
||||
default:
|
||||
log.Error("Unable to retrieve Github email ", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// isMember makes sure that the user is in one of the required organizations
|
||||
func isMember(requiredOrgs []string, userOrgs []*github.Organization) bool {
|
||||
for _, requiredOrg := range requiredOrgs {
|
||||
for _, userOrg := range userOrgs {
|
||||
if userOrg.Login != nil && *userOrg.Login == requiredOrg {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getOrganizations gets all organization for the currently authenticated user
|
||||
func getOrganizations(client *github.Client, log chronograf.Logger) ([]*github.Organization, error) {
|
||||
// Get all pages of results
|
||||
var allOrgs []*github.Organization
|
||||
for {
|
||||
opt := &github.ListOptions{
|
||||
PerPage: 10,
|
||||
}
|
||||
// Get the organizations for the current authenticated user.
|
||||
orgs, resp, err := client.Organizations.List("", opt)
|
||||
if err != nil {
|
||||
logResponseError(log, resp, err)
|
||||
return nil, err
|
||||
}
|
||||
allOrgs = append(allOrgs, orgs...)
|
||||
if resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
opt.Page = resp.NextPage
|
||||
}
|
||||
return allOrgs, nil
|
||||
}
|
||||
|
||||
// getPrimaryEmail gets the primary email account for the authenticated user.
|
||||
func getPrimaryEmail(client *github.Client, log chronograf.Logger) (string, error) {
|
||||
emails, resp, err := client.Users.ListEmails(nil)
|
||||
if err != nil {
|
||||
logResponseError(log, resp, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
email, err := primaryEmail(emails)
|
||||
if err != nil {
|
||||
log.Error("Unable to retrieve primary Github email ", err.Error())
|
||||
return "", err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func primaryEmail(emails []*github.UserEmail) (string, error) {
|
||||
for _, m := range emails {
|
||||
if m != nil && m.Primary != nil && m.Verified != nil && m.Email != nil {
|
||||
return *m.Email, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("No primary email address")
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
package oauth2_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
clog "github.com/influxdata/chronograf/log"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
func TestGithubPrincipalID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := []struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}{
|
||||
{"martymcfly@example.com", true, false},
|
||||
}
|
||||
mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/user/emails" {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
enc := json.NewEncoder(rw)
|
||||
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = enc.Encode(expected)
|
||||
}))
|
||||
defer mockAPI.Close()
|
||||
|
||||
logger := clog.New(clog.ParseLevel("debug"))
|
||||
prov := oauth2.Github{
|
||||
Logger: logger,
|
||||
}
|
||||
tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal("Error initializing TestTripper: err:", err)
|
||||
}
|
||||
|
||||
tc := &http.Client{
|
||||
Transport: tt,
|
||||
}
|
||||
|
||||
email, err := prov.PrincipalID(tc)
|
||||
if err != nil {
|
||||
t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err)
|
||||
}
|
||||
|
||||
if email != expected[0].Email {
|
||||
t.Fatal("Retrieved email was not as expected. Want:", expected[0].Email, "Got:", email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGithubPrincipalIDOrganization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expectedUser := []struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}{
|
||||
{"martymcfly@example.com", true, false},
|
||||
}
|
||||
expectedOrg := []struct {
|
||||
Login string `json:"login"`
|
||||
}{
|
||||
{"Hill Valley Preservation Society"},
|
||||
}
|
||||
|
||||
mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/user/emails" {
|
||||
enc := json.NewEncoder(rw)
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = enc.Encode(expectedUser)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/user/orgs" {
|
||||
enc := json.NewEncoder(rw)
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = enc.Encode(expectedOrg)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer mockAPI.Close()
|
||||
|
||||
logger := clog.New(clog.ParseLevel("debug"))
|
||||
prov := oauth2.Github{
|
||||
Logger: logger,
|
||||
Orgs: []string{"Hill Valley Preservation Society"},
|
||||
}
|
||||
tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal("Error initializing TestTripper: err:", err)
|
||||
}
|
||||
|
||||
tc := &http.Client{
|
||||
Transport: tt,
|
||||
}
|
||||
|
||||
email, err := prov.PrincipalID(tc)
|
||||
if err != nil {
|
||||
t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err)
|
||||
}
|
||||
|
||||
if email != expectedUser[0].Email {
|
||||
t.Fatal("Retrieved email was not as expected. Want:", expectedUser[0].Email, "Got:", email)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
package oauth2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
goauth2 "google.golang.org/api/oauth2/v2"
|
||||
)
|
||||
|
||||
// Endpoint is Google's OAuth 2.0 endpoint.
|
||||
// Copied here to remove tons of package dependencies
|
||||
var GoogleEndpoint = oauth2.Endpoint{
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/auth",
|
||||
TokenURL: "https://accounts.google.com/o/oauth2/token",
|
||||
}
|
||||
var _ Provider = &Google{}
|
||||
|
||||
type Google struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
Domains []string // Optional google email domain checking
|
||||
Logger chronograf.Logger
|
||||
}
|
||||
|
||||
// Name is the name of the provider
|
||||
func (g *Google) Name() string {
|
||||
return "google"
|
||||
}
|
||||
|
||||
// ID returns the google application client id
|
||||
func (g *Google) ID() string {
|
||||
return g.ClientID
|
||||
}
|
||||
|
||||
// Secret returns the google application client secret
|
||||
func (g *Google) Secret() string {
|
||||
return g.ClientSecret
|
||||
}
|
||||
|
||||
// Scopes for google is only the email address
|
||||
// Documentation is here: https://developers.google.com/+/web/api/rest/oauth#email
|
||||
func (g *Google) Scopes() []string {
|
||||
return []string{
|
||||
goauth2.UserinfoEmailScope,
|
||||
goauth2.UserinfoProfileScope,
|
||||
}
|
||||
}
|
||||
|
||||
// Config is the Google OAuth2 exchange information and endpoints
|
||||
func (g *Google) Config() *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: g.ID(),
|
||||
ClientSecret: g.Secret(),
|
||||
Scopes: g.Scopes(),
|
||||
Endpoint: GoogleEndpoint,
|
||||
RedirectURL: g.RedirectURL,
|
||||
}
|
||||
}
|
||||
|
||||
// PrincipalID returns the google email address of the user.
|
||||
func (g *Google) PrincipalID(provider *http.Client) (string, error) {
|
||||
srv, err := goauth2.New(provider)
|
||||
if err != nil {
|
||||
g.Logger.Error("Unable to communicate with Google ", err.Error())
|
||||
return "", err
|
||||
}
|
||||
info, err := srv.Userinfo.Get().Do()
|
||||
if err != nil {
|
||||
g.Logger.Error("Unable to retrieve Google email ", err.Error())
|
||||
return "", err
|
||||
}
|
||||
// No domain filtering required, so, the user is autenticated.
|
||||
if len(g.Domains) == 0 {
|
||||
return info.Email, nil
|
||||
}
|
||||
|
||||
// Check if the account domain is acceptable
|
||||
for _, requiredDomain := range g.Domains {
|
||||
if info.Hd == requiredDomain {
|
||||
return info.Email, nil
|
||||
}
|
||||
}
|
||||
g.Logger.Error("Domain '", info.Hd, "' is not a member of required Google domain(s): ", g.Domains)
|
||||
return "", fmt.Errorf("Not in required domain")
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
package oauth2_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
clog "github.com/influxdata/chronograf/log"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
func TestGooglePrincipalID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := struct {
|
||||
Email string `json:"email"`
|
||||
}{
|
||||
"martymcfly@example.com",
|
||||
}
|
||||
mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/oauth2/v2/userinfo" {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(rw)
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = enc.Encode(expected)
|
||||
}))
|
||||
defer mockAPI.Close()
|
||||
|
||||
logger := clog.New(clog.ParseLevel("debug"))
|
||||
prov := oauth2.Google{
|
||||
Logger: logger,
|
||||
}
|
||||
tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal("Error initializing TestTripper: err:", err)
|
||||
}
|
||||
|
||||
tc := &http.Client{
|
||||
Transport: tt,
|
||||
}
|
||||
|
||||
email, err := prov.PrincipalID(tc)
|
||||
if err != nil {
|
||||
t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err)
|
||||
}
|
||||
|
||||
if email != expected.Email {
|
||||
t.Fatal("Retrieved email was not as expected. Want:", expected.Email, "Got:", email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooglePrincipalIDDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expectedUser := struct {
|
||||
Email string `json:"email"`
|
||||
Hd string `json:"hd"`
|
||||
}{
|
||||
"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)
|
||||
return
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(rw)
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = enc.Encode(expectedUser)
|
||||
}))
|
||||
defer mockAPI.Close()
|
||||
|
||||
logger := clog.New(clog.ParseLevel("debug"))
|
||||
prov := oauth2.Google{
|
||||
Logger: logger,
|
||||
Domains: []string{"Hill Valley Preservation Society"},
|
||||
}
|
||||
tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal("Error initializing TestTripper: err:", err)
|
||||
}
|
||||
|
||||
tc := &http.Client{
|
||||
Transport: tt,
|
||||
}
|
||||
|
||||
email, err := prov.PrincipalID(tc)
|
||||
if err != nil {
|
||||
t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err)
|
||||
}
|
||||
|
||||
if email != expectedUser.Email {
|
||||
t.Fatal("Retrieved email was not as expected. Want:", expectedUser.Email, "Got:", email)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
package oauth2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
hrk "golang.org/x/oauth2/heroku"
|
||||
)
|
||||
|
||||
// Ensure that Heroku is an oauth2.Provider
|
||||
var _ Provider = &Heroku{}
|
||||
|
||||
const (
|
||||
// Routes required for interacting with Heroku API
|
||||
HEROKU_ACCOUNT_ROUTE string = "https://api.heroku.com/account"
|
||||
)
|
||||
|
||||
// Heroku is an OAuth2 Provider allowing users to authenticate with Heroku to
|
||||
// gain access to Chronograf
|
||||
type Heroku struct {
|
||||
// OAuth2 Secrets
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
|
||||
Organizations []string // set of organizations permitted to access the protected resource. Empty means "all"
|
||||
|
||||
Logger chronograf.Logger
|
||||
}
|
||||
|
||||
// Config returns the OAuth2 exchange information and endpoints
|
||||
func (h *Heroku) Config() *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: h.ID(),
|
||||
ClientSecret: h.Secret(),
|
||||
Scopes: h.Scopes(),
|
||||
Endpoint: hrk.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// ID returns the Heroku application client ID
|
||||
func (h *Heroku) ID() string {
|
||||
return h.ClientID
|
||||
}
|
||||
|
||||
// Name returns the name of this provider (heroku)
|
||||
func (h *Heroku) Name() string {
|
||||
return "heroku"
|
||||
}
|
||||
|
||||
// PrincipalID returns the Heroku email address of the user.
|
||||
func (h *Heroku) PrincipalID(provider *http.Client) (string, error) {
|
||||
type DefaultOrg struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type Account struct {
|
||||
Email string `json:"email"`
|
||||
DefaultOrganization DefaultOrg `json:"default_organization"`
|
||||
}
|
||||
|
||||
resp, err := provider.Get(HEROKU_ACCOUNT_ROUTE)
|
||||
if err != nil {
|
||||
h.Logger.Error("Unable to communicate with Heroku. err:", err)
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
d := json.NewDecoder(resp.Body)
|
||||
var account Account
|
||||
if err := d.Decode(&account); err != nil {
|
||||
h.Logger.Error("Unable to decode response from Heroku. err:", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// check if member of org
|
||||
if len(h.Organizations) > 0 {
|
||||
for _, org := range h.Organizations {
|
||||
if account.DefaultOrganization.Name == org {
|
||||
return account.Email, nil
|
||||
}
|
||||
}
|
||||
h.Logger.Error(ErrOrgMembership)
|
||||
return "", ErrOrgMembership
|
||||
} else {
|
||||
return account.Email, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Scopes for heroku is "identity" which grants access to user account
|
||||
// information. This will grant us access to the user's email address which is
|
||||
// used as the Principal's identifier.
|
||||
func (h *Heroku) Scopes() []string {
|
||||
return []string{"identity"}
|
||||
}
|
||||
|
||||
// Secret returns the Heroku application client secret
|
||||
func (h *Heroku) Secret() string {
|
||||
return h.ClientSecret
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
package oauth2_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
clog "github.com/influxdata/chronograf/log"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
func Test_Heroku_PrincipalID_ExtractsEmailAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := struct {
|
||||
Email string `json:"email"`
|
||||
}{
|
||||
"martymcfly@example.com",
|
||||
}
|
||||
|
||||
mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/account" {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
enc := json.NewEncoder(rw)
|
||||
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = enc.Encode(expected)
|
||||
}))
|
||||
defer mockAPI.Close()
|
||||
|
||||
logger := clog.New(clog.ParseLevel("debug"))
|
||||
prov := oauth2.Heroku{
|
||||
Logger: logger,
|
||||
}
|
||||
tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal("Error initializing TestTripper: err:", err)
|
||||
}
|
||||
|
||||
tc := &http.Client{
|
||||
Transport: tt,
|
||||
}
|
||||
|
||||
email, err := prov.PrincipalID(tc)
|
||||
if err != nil {
|
||||
t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err)
|
||||
}
|
||||
|
||||
if email != expected.Email {
|
||||
t.Fatal("Retrieved email was not as expected. Want:", expected.Email, "Got:", email)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Heroku_PrincipalID_RestrictsByOrganization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := struct {
|
||||
Email string `json:"email"`
|
||||
DefaultOrganization map[string]string `json:"default_organization"`
|
||||
}{
|
||||
"martymcfly@example.com",
|
||||
map[string]string{
|
||||
"id": "a85eac89-56cc-498e-9a89-d8f49f6aed71",
|
||||
"name": "hill-valley-preservation-society",
|
||||
},
|
||||
}
|
||||
|
||||
mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/account" {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
enc := json.NewEncoder(rw)
|
||||
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = enc.Encode(expected)
|
||||
}))
|
||||
defer mockAPI.Close()
|
||||
|
||||
logger := clog.New(clog.ParseLevel("debug"))
|
||||
prov := oauth2.Heroku{
|
||||
Logger: logger,
|
||||
Organizations: []string{"enchantment-under-the-sea-dance-committee"},
|
||||
}
|
||||
|
||||
tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal("Error initializing TestTripper: err:", err)
|
||||
}
|
||||
|
||||
tc := &http.Client{
|
||||
Transport: tt,
|
||||
}
|
||||
|
||||
_, err = prov.PrincipalID(tc)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error while authenticating user with mismatched orgs, but received none")
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package jwt
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
@ -6,11 +6,10 @@ import (
|
|||
"time"
|
||||
|
||||
gojwt "github.com/dgrijalva/jwt-go"
|
||||
"github.com/influxdata/chronograf"
|
||||
)
|
||||
|
||||
// Test if JWT implements Authenticator
|
||||
var _ chronograf.Authenticator = &JWT{}
|
||||
var _ Authenticator = &JWT{}
|
||||
|
||||
// JWT represents a javascript web token that can be validated or marshaled into string.
|
||||
type JWT struct {
|
||||
|
@ -45,7 +44,7 @@ func (c *Claims) Valid() error {
|
|||
}
|
||||
|
||||
// Authenticate checks if the jwtToken is signed correctly and validates with Claims.
|
||||
func (j *JWT) Authenticate(ctx context.Context, jwtToken string) (chronograf.Principal, error) {
|
||||
func (j *JWT) Authenticate(ctx context.Context, jwtToken string) (Principal, error) {
|
||||
gojwt.TimeFunc = j.Now
|
||||
|
||||
// Check for expected signing method.
|
||||
|
@ -62,27 +61,31 @@ func (j *JWT) Authenticate(ctx context.Context, jwtToken string) (chronograf.Pri
|
|||
// 4. Check if subject is not empty
|
||||
token, err := gojwt.ParseWithClaims(jwtToken, &Claims{}, alg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return Principal{}, err
|
||||
} else if !token.Valid {
|
||||
return "", err
|
||||
return Principal{}, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unable to convert claims to standard claims")
|
||||
return Principal{}, fmt.Errorf("unable to convert claims to standard claims")
|
||||
}
|
||||
|
||||
return chronograf.Principal(claims.Subject), nil
|
||||
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 chronograf.Principal, duration time.Duration) (string, error) {
|
||||
func (j *JWT) Token(ctx context.Context, user Principal, duration time.Duration) (string, error) {
|
||||
// Create a new token object, specifying signing method and the claims
|
||||
// you would like it to contain.
|
||||
now := j.Now().UTC()
|
||||
claims := &Claims{
|
||||
gojwt.StandardClaims{
|
||||
Subject: string(user),
|
||||
Subject: user.Subject,
|
||||
Issuer: user.Issuer,
|
||||
ExpiresAt: now.Add(duration).Unix(),
|
||||
IssuedAt: now.Unix(),
|
||||
NotBefore: now.Unix(),
|
|
@ -1,4 +1,4 @@
|
|||
package jwt_test
|
||||
package oauth2_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
@ -6,8 +6,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
"github.com/influxdata/chronograf/jwt"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
func TestAuthenticate(t *testing.T) {
|
||||
|
@ -15,46 +14,56 @@ func TestAuthenticate(t *testing.T) {
|
|||
Desc string
|
||||
Secret string
|
||||
Token string
|
||||
User chronograf.Principal
|
||||
User oauth2.Principal
|
||||
Err error
|
||||
}{
|
||||
{
|
||||
Desc: "Test bad jwt token",
|
||||
Secret: "secret",
|
||||
Token: "badtoken",
|
||||
User: "",
|
||||
Err: errors.New("token contains an invalid number of segments"),
|
||||
User: 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: "/chronograf/v1/users/1",
|
||||
User: oauth2.Principal{
|
||||
Subject: "/chronograf/v1/users/1",
|
||||
},
|
||||
},
|
||||
{
|
||||
Desc: "Test expired jwt token",
|
||||
Secret: "secret",
|
||||
Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0NDAxLCJuYmYiOi00NDY3NzQ0MDB9.vWXdm0-XQ_pW62yBpSISFFJN_yz0vqT9_INcUKTp5Q8",
|
||||
User: "",
|
||||
Err: errors.New("token is expired by 1s"),
|
||||
User: 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: "",
|
||||
Err: errors.New("token is not valid yet"),
|
||||
User: 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: "",
|
||||
Err: errors.New("claim has no subject"),
|
||||
User: oauth2.Principal{
|
||||
Subject: "",
|
||||
},
|
||||
Err: errors.New("claim has no subject"),
|
||||
},
|
||||
}
|
||||
for i, test := range tests {
|
||||
j := jwt.JWT{
|
||||
j := oauth2.JWT{
|
||||
Secret: test.Secret,
|
||||
Now: func() time.Time {
|
||||
return time.Unix(-446774400, 0)
|
||||
|
@ -77,13 +86,16 @@ func TestAuthenticate(t *testing.T) {
|
|||
func TestToken(t *testing.T) {
|
||||
duration := time.Second
|
||||
expected := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOi00NDY3NzQzOTksImlhdCI6LTQ0Njc3NDQwMCwibmJmIjotNDQ2Nzc0NDAwLCJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIn0.ofQM6yTmrmve5JeEE0RcK4_euLXuZ_rdh6bLAbtbC9M"
|
||||
j := jwt.JWT{
|
||||
j := oauth2.JWT{
|
||||
Secret: "secret",
|
||||
Now: func() time.Time {
|
||||
return time.Unix(-446774400, 0)
|
||||
},
|
||||
}
|
||||
if token, err := j.Token(context.Background(), chronograf.Principal("/chronograf/v1/users/1"), duration); err != nil {
|
||||
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)
|
||||
} else if token != expected {
|
||||
t.Errorf("Error creating token; expected: %s actual: %s", "", token)
|
|
@ -0,0 +1,169 @@
|
|||
package oauth2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
"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
|
||||
)
|
||||
|
||||
// Cookie represents the location and expiration time of new cookies.
|
||||
type cookie struct {
|
||||
Name string
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// Check to ensure CookieMux is an oauth2.Mux
|
||||
var _ Mux = &CookieMux{}
|
||||
|
||||
func NewCookieMux(p Provider, a Authenticator, l chronograf.Logger) *CookieMux {
|
||||
return &CookieMux{
|
||||
Provider: p,
|
||||
Auth: a,
|
||||
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
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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.
|
||||
// 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 is likely an internal server error
|
||||
if err != nil {
|
||||
j.Logger.
|
||||
WithField("component", "auth").
|
||||
WithField("remote_addr", r.RemoteAddr).
|
||||
WithField("method", r.Method).
|
||||
WithField("url", r.URL).
|
||||
Error("Internal authentication error: ", err.Error())
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
url := conf.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||
})
|
||||
}
|
||||
|
||||
// Callback is used by OAuth2 provider after authorization is granted. If
|
||||
// granted, Callback will set a cookie with a month-long expiration. It is
|
||||
// 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 {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log := j.Logger.
|
||||
WithField("component", "auth").
|
||||
WithField("remote_addr", r.RemoteAddr).
|
||||
WithField("method", r.Method).
|
||||
WithField("url", r.URL)
|
||||
|
||||
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 {
|
||||
log.Error("Invalid OAuth state received: ", err.Error())
|
||||
http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange the code back with the provider to the the token
|
||||
conf := j.Provider.Config()
|
||||
code := r.FormValue("code")
|
||||
token, err := conf.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
log.Error("Unable to exchange code for token ", err.Error())
|
||||
http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Using the token get the principal identifier from the provider
|
||||
oauthClient := conf.Client(r.Context(), token)
|
||||
id, err := j.Provider.PrincipalID(oauthClient)
|
||||
if err != nil {
|
||||
log.Error("Unable to get principal identifier ", err.Error())
|
||||
http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
p := Principal{
|
||||
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)
|
||||
if err != nil {
|
||||
log.Error("Unable to create cookie auth token ", 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)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,157 @@
|
|||
package oauth2_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"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)
|
||||
|
||||
// setupMuxTest produces an http.Client and an httptest.Server configured to
|
||||
// use a particular http.Handler selected from a CookieMux. 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) {
|
||||
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 {
|
||||
return testTime
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(selector(jm))
|
||||
|
||||
jar, _ := cookiejar.New(nil)
|
||||
|
||||
hc := http.Client{
|
||||
Jar: jar,
|
||||
CheckRedirect: func(r *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
return &hc, ts, provider
|
||||
}
|
||||
|
||||
// teardownMuxTest cleans up any resources created by setupMuxTest. This should
|
||||
// be deferred in your test after setupMuxTest is called
|
||||
func teardownMuxTest(hc *http.Client, backend *httptest.Server, provider *httptest.Server) {
|
||||
provider.Close()
|
||||
backend.Close()
|
||||
}
|
||||
|
||||
func Test_CookieMux_Logout_DeletesSessionCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hc, ts, prov := setupMuxTest(func(j *oauth2.CookieMux) http.Handler {
|
||||
return j.Logout()
|
||||
})
|
||||
defer teardownMuxTest(hc, ts, prov)
|
||||
|
||||
tsUrl, _ := url.Parse(ts.URL)
|
||||
|
||||
hc.Jar.SetCookies(tsUrl, []*http.Cookie{
|
||||
&http.Cookie{
|
||||
Name: oauth2.DefaultCookieName,
|
||||
Value: "",
|
||||
},
|
||||
})
|
||||
|
||||
resp, err := hc.Get(ts.URL)
|
||||
if err != nil {
|
||||
t.Fatal("Error communicating with Logout() handler: err:", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 300 || resp.StatusCode >= 400 {
|
||||
t.Fatal("Expected to be redirected, but received status code", resp.StatusCode)
|
||||
}
|
||||
|
||||
cookies := resp.Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatal("Expected that cookie would be present but wasn't")
|
||||
}
|
||||
|
||||
c := cookies[0]
|
||||
if c.Name != oauth2.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) {
|
||||
t.Parallel()
|
||||
|
||||
hc, ts, prov := setupMuxTest(func(j *oauth2.CookieMux) http.Handler {
|
||||
return j.Login() // Use Login handler for httptest server.
|
||||
})
|
||||
defer teardownMuxTest(hc, ts, prov)
|
||||
|
||||
resp, err := hc.Get(ts.URL)
|
||||
if err != nil {
|
||||
t.Fatal("Error communicating with Login() handler: err:", err)
|
||||
}
|
||||
|
||||
// Ensure we were redirected
|
||||
if resp.StatusCode < 300 || resp.StatusCode >= 400 {
|
||||
t.Fatal("Expected to be redirected, but received status code", resp.StatusCode)
|
||||
}
|
||||
|
||||
loc, err := resp.Location()
|
||||
if err != nil {
|
||||
t.Fatal("Expected a location to be redirected to, but wasn't present")
|
||||
}
|
||||
|
||||
if state := loc.Query().Get("state"); state != "HELLO?!MCFLY?!ANYONEINTHERE?!" {
|
||||
t.Fatal("Expected state to be set but was", state)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_CookieMux_Callback_SetsCookie(t *testing.T) {
|
||||
hc, ts, prov := setupMuxTest(func(j *oauth2.CookieMux) http.Handler {
|
||||
return j.Callback()
|
||||
})
|
||||
defer teardownMuxTest(hc, ts, prov)
|
||||
|
||||
tsURL, _ := url.Parse(ts.URL)
|
||||
|
||||
v := url.Values{
|
||||
"code": {"4815162342"},
|
||||
"state": {"foobar"},
|
||||
}
|
||||
|
||||
tsURL.RawQuery = v.Encode()
|
||||
|
||||
resp, err := hc.Get(tsURL.String())
|
||||
if err != nil {
|
||||
t.Fatal("Error communicating with Callback() handler: err", err)
|
||||
}
|
||||
|
||||
// Ensure we were redirected
|
||||
if resp.StatusCode < 300 || resp.StatusCode >= 400 {
|
||||
t.Fatal("Expected to be redirected, but received status code", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Check that cookie was set
|
||||
cookies := resp.Cookies()
|
||||
if count := len(cookies); count != 1 {
|
||||
t.Fatal("Expected exactly one cookie to be set but found", count)
|
||||
}
|
||||
|
||||
c := cookies[0]
|
||||
|
||||
if c.Name != oauth2.DefaultCookieName {
|
||||
t.Fatal("Expected cookie to be named", oauth2.DefaultCookieName, "but was", c.Name)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
/* Constants */
|
||||
const (
|
||||
// PrincipalKey is used to pass principal
|
||||
// via context.Context to request-scoped
|
||||
// functions.
|
||||
PrincipalKey string = "principal"
|
||||
)
|
||||
|
||||
var (
|
||||
/* Errors */
|
||||
ErrAuthentication = errors.New("user not authenticated")
|
||||
ErrOrgMembership = errors.New("Not a member of the required organization")
|
||||
)
|
||||
|
||||
/* Types */
|
||||
|
||||
// Principal is any entity that can be authenticated
|
||||
type Principal struct {
|
||||
Subject string
|
||||
Issuer string
|
||||
}
|
||||
|
||||
/* Interfaces */
|
||||
|
||||
// Provider are the common parameters for all providers (RFC 6749)
|
||||
type Provider interface {
|
||||
// ID is issued to the registered client by the authorization (RFC 6749 Section 2.2)
|
||||
ID() string
|
||||
// Secret associated is with the ID (Section 2.2)
|
||||
Secret() string
|
||||
// Scopes is used by the authorization server to "scope" responses (Section 3.3)
|
||||
Scopes() []string
|
||||
// Config is the OAuth2 configuration settings for this provider
|
||||
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
|
||||
}
|
||||
|
||||
// Mux is a collection of handlers responsible for servicing an Oauth2 interaction between a browser and a provider
|
||||
type Mux interface {
|
||||
Login() http.Handler
|
||||
Logout() http.Handler
|
||||
Callback() http.Handler
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// TokenExtractor extracts tokens from http requests
|
||||
type TokenExtractor interface {
|
||||
// Extract will return the token or an error.
|
||||
Extract(r *http.Request) (string, error)
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
package oauth2_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
goauth "golang.org/x/oauth2"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
var _ oauth2.Provider = &MockProvider{}
|
||||
|
||||
type MockProvider struct {
|
||||
Email string
|
||||
|
||||
ProviderURL string
|
||||
}
|
||||
|
||||
func (mp *MockProvider) Config() *goauth.Config {
|
||||
return &goauth.Config{
|
||||
RedirectURL: "http://www.example.com",
|
||||
ClientID: "4815162342",
|
||||
ClientSecret: "8675309",
|
||||
Endpoint: goauth.Endpoint{
|
||||
mp.ProviderURL + "/oauth/auth",
|
||||
mp.ProviderURL + "/oauth/token",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (mp *MockProvider) ID() string {
|
||||
return "8675309"
|
||||
}
|
||||
|
||||
func (mp *MockProvider) Name() string {
|
||||
return "mockly"
|
||||
}
|
||||
|
||||
func (mp *MockProvider) PrincipalID(provider *http.Client) (string, error) {
|
||||
return mp.Email, nil
|
||||
}
|
||||
|
||||
func (mp *MockProvider) Scopes() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (mp *MockProvider) Secret() string {
|
||||
return "4815162342"
|
||||
}
|
||||
|
||||
var _ oauth2.Authenticator = &YesManAuthenticator{}
|
||||
|
||||
type YesManAuthenticator struct{}
|
||||
|
||||
func (y *YesManAuthenticator) Authenticate(ctx context.Context, token string) (oauth2.Principal, error) {
|
||||
return oauth2.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 NewTestTripper(log chronograf.Logger, ts *httptest.Server, rt http.RoundTripper) (*TestTripper, error) {
|
||||
url, err := url.Parse(ts.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TestTripper{log, rt, url}, nil
|
||||
}
|
||||
|
||||
type TestTripper struct {
|
||||
Log chronograf.Logger
|
||||
|
||||
rt http.RoundTripper
|
||||
tsURL *url.URL
|
||||
}
|
||||
|
||||
// RoundTrip modifies the Hostname of the incoming request to be directed to the
|
||||
// test server.
|
||||
func (tt *TestTripper) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
tt.Log.
|
||||
WithField("component", "test").
|
||||
WithField("remote_addr", r.RemoteAddr).
|
||||
WithField("method", r.Method).
|
||||
WithField("url", r.URL).
|
||||
Info("Request")
|
||||
|
||||
r.URL.Host = tt.tsURL.Host
|
||||
r.URL.Scheme = tt.tsURL.Scheme
|
||||
|
||||
return tt.rt.RoundTrip(r)
|
||||
}
|
279
server/github.go
279
server/github.go
|
@ -1,279 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-github/github"
|
||||
"github.com/influxdata/chronograf"
|
||||
"golang.org/x/oauth2"
|
||||
ogh "golang.org/x/oauth2/github"
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
// Cookie represents the location and expiration time of new cookies.
|
||||
type Cookie struct {
|
||||
Name string
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// NewCookie creates a Cookie with DefaultCookieName and DefaultCookieDuration
|
||||
func NewCookie() Cookie {
|
||||
return Cookie{
|
||||
Name: DefaultCookieName,
|
||||
Duration: DefaultCookieDuration,
|
||||
}
|
||||
}
|
||||
|
||||
// Github provides OAuth Login and Callback server. Callback will set
|
||||
// an authentication cookie. This cookie's value is a JWT containing
|
||||
// the user's primary Github email address.
|
||||
type Github struct {
|
||||
Cookie Cookie
|
||||
Authenticator chronograf.Authenticator
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Scopes []string
|
||||
SuccessURL string // SuccessURL is redirect location after successful authorization
|
||||
FailureURL string // FailureURL is redirect location after authorization failure
|
||||
Orgs []string // Optional github organization checking
|
||||
Now func() time.Time
|
||||
Logger chronograf.Logger
|
||||
}
|
||||
|
||||
// NewGithub constructs a Github with default cookie behavior and scopes.
|
||||
func NewGithub(clientID, clientSecret, successURL, failureURL string, orgs []string, auth chronograf.Authenticator, log chronograf.Logger) Github {
|
||||
scopes := []string{"user:email"}
|
||||
if len(orgs) > 0 {
|
||||
scopes = append(scopes, "read:org")
|
||||
}
|
||||
return Github{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
Cookie: NewCookie(),
|
||||
Scopes: scopes,
|
||||
Orgs: orgs,
|
||||
SuccessURL: successURL,
|
||||
FailureURL: failureURL,
|
||||
Authenticator: auth,
|
||||
Now: time.Now,
|
||||
Logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Github) config() *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: g.ClientID,
|
||||
ClientSecret: g.ClientSecret,
|
||||
Scopes: g.Scopes,
|
||||
Endpoint: ogh.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// Login returns a handler that redirects to Github's OAuth login.
|
||||
// Uses JWT with a random string as the state validation method.
|
||||
// JWTs are used because they can be validated without storing
|
||||
// state.
|
||||
func (g *Github) Login() http.HandlerFunc {
|
||||
conf := g.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.
|
||||
// If the callback is not received within 10 minutes, then authorization will fail.
|
||||
csrf := randomString(32) // 32 is not important... just long
|
||||
state, err := g.Authenticator.Token(r.Context(), chronograf.Principal(csrf), 10*time.Minute)
|
||||
// This is likely an internal server error
|
||||
if err != nil {
|
||||
g.Logger.
|
||||
WithField("component", "auth").
|
||||
WithField("remote_addr", r.RemoteAddr).
|
||||
WithField("method", r.Method).
|
||||
WithField("url", r.URL).
|
||||
Error("Internal authentication error: ", err.Error())
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
url := conf.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||
})
|
||||
}
|
||||
|
||||
// Logout will expire our authentication cookie and redirect to the SuccessURL
|
||||
func (g *Github) Logout() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteCookie := http.Cookie{
|
||||
Name: g.Cookie.Name,
|
||||
Value: "none",
|
||||
Expires: g.Now().UTC().Add(-1 * time.Hour),
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
}
|
||||
http.SetCookie(w, &deleteCookie)
|
||||
http.Redirect(w, r, g.SuccessURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
}
|
||||
|
||||
// Callback used by github callback after authorization is granted. If
|
||||
// granted, Callback will set a cookie with a month-long expiration. The
|
||||
// value of the cookie is a JWT because the JWT can be validated without
|
||||
// the need for saving state. The JWT contains the Github user's primary
|
||||
// email address.
|
||||
func (g *Github) Callback() http.HandlerFunc {
|
||||
conf := g.config()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log := g.Logger.
|
||||
WithField("component", "auth").
|
||||
WithField("remote_addr", r.RemoteAddr).
|
||||
WithField("method", r.Method).
|
||||
WithField("url", r.URL)
|
||||
|
||||
state := r.FormValue("state")
|
||||
// Check if the OAuth state token is valid to prevent CSRF
|
||||
_, err := g.Authenticator.Authenticate(r.Context(), state)
|
||||
if err != nil {
|
||||
log.Error("Invalid OAuth state received: ", err.Error())
|
||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.FormValue("code")
|
||||
token, err := conf.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
log.Error("Unable to exchange code for token ", err.Error())
|
||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
oauthClient := conf.Client(r.Context(), token)
|
||||
client := github.NewClient(oauthClient)
|
||||
// If we need to restrict to a set of organizations, we first get the org
|
||||
// and filter.
|
||||
if len(g.Orgs) > 0 {
|
||||
orgs, err := getOrganizations(client, log)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
// Not a member, so, deny permission
|
||||
if ok := isMember(g.Orgs, orgs); !ok {
|
||||
log.Error("Not a member of required github organization")
|
||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
email, err := getPrimaryEmail(client, log)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// We create an auth token that will be used by all other endpoints to validate the principal has a claim
|
||||
authToken, err := g.Authenticator.Token(r.Context(), chronograf.Principal(email), g.Cookie.Duration)
|
||||
if err != nil {
|
||||
log.Error("Unable to create cookie auth token ", err.Error())
|
||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
expireCookie := time.Now().UTC().Add(g.Cookie.Duration)
|
||||
cookie := http.Cookie{
|
||||
Name: g.Cookie.Name,
|
||||
Value: authToken,
|
||||
Expires: expireCookie,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
}
|
||||
log.Info("User ", email, " is authenticated")
|
||||
http.SetCookie(w, &cookie)
|
||||
http.Redirect(w, r, g.SuccessURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
}
|
||||
|
||||
func randomString(length int) string {
|
||||
k := make([]byte, length)
|
||||
if _, err := io.ReadFull(rand.Reader, k); err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k)
|
||||
}
|
||||
|
||||
func logResponseError(log chronograf.Logger, resp *github.Response, err error) {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
log.Error("OAuth access to email address forbidden ", err.Error())
|
||||
default:
|
||||
log.Error("Unable to retrieve Github email ", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// isMember makes sure that the user is in one of the required organizations
|
||||
func isMember(requiredOrgs []string, userOrgs []*github.Organization) bool {
|
||||
for _, requiredOrg := range requiredOrgs {
|
||||
for _, userOrg := range userOrgs {
|
||||
if userOrg.Login != nil && *userOrg.Login == requiredOrg {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getOrganizations gets all organization for the currently authenticated user
|
||||
func getOrganizations(client *github.Client, log chronograf.Logger) ([]*github.Organization, error) {
|
||||
// Get all pages of results
|
||||
var allOrgs []*github.Organization
|
||||
for {
|
||||
opt := &github.ListOptions{
|
||||
PerPage: 10,
|
||||
}
|
||||
// Get the organizations for the current authenticated user.
|
||||
orgs, resp, err := client.Organizations.List("", opt)
|
||||
if err != nil {
|
||||
logResponseError(log, resp, err)
|
||||
return nil, err
|
||||
}
|
||||
allOrgs = append(allOrgs, orgs...)
|
||||
if resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
opt.Page = resp.NextPage
|
||||
}
|
||||
return allOrgs, nil
|
||||
}
|
||||
|
||||
// getPrimaryEmail gets the primary email account for the authenticated user.
|
||||
func getPrimaryEmail(client *github.Client, log chronograf.Logger) (string, error) {
|
||||
emails, resp, err := client.Users.ListEmails(nil)
|
||||
if err != nil {
|
||||
logResponseError(log, resp, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
email, err := primaryEmail(emails)
|
||||
if err != nil {
|
||||
log.Error("Unable to retrieve primary Github email ", err.Error())
|
||||
return "", err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func primaryEmail(emails []*github.UserEmail) (string, error) {
|
||||
for _, m := range emails {
|
||||
if m != nil && m.Primary != nil && m.Verified != nil && m.Email != nil {
|
||||
return *m.Email, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("No primary email address")
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package server
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Logout chooses the correct provider logout route and redirects to it
|
||||
func Logout(nextURL string, routes AuthRoutes) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
principal, err := getPrincipal(ctx)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, nextURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
route, ok := routes.Lookup(principal.Issuer)
|
||||
if !ok {
|
||||
http.Redirect(w, r, nextURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, route.Logout, http.StatusTemporaryRedirect)
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ import (
|
|||
"github.com/NYTimes/gziphandler"
|
||||
"github.com/bouk/httprouter"
|
||||
"github.com/influxdata/chronograf" // When julienschmidt/httprouter v2 w/ context is out, switch
|
||||
"github.com/influxdata/chronograf/jwt"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -20,13 +20,13 @@ 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
|
||||
UseAuth bool // UseAuth turns on Github OAuth and JWT
|
||||
TokenSecret string // TokenSecret is the JWT secret
|
||||
GithubClientID string // GithubClientID is the GH OAuth id
|
||||
GithubClientSecret string // GithubClientSecret is the GH OAuth secret
|
||||
GithubOrgs []string // GithubOrgs is the list of organizations a user my be a member of
|
||||
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
|
||||
UseAuth bool // UseAuth turns on Github OAuth and JWT
|
||||
TokenSecret string
|
||||
|
||||
ProviderFuncs []func(func(oauth2.Provider, oauth2.Mux))
|
||||
}
|
||||
|
||||
// NewMux attaches all the route handlers; handler returned servers chronograf.
|
||||
|
@ -55,9 +55,6 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
|
|||
router.GET("/docs", Redoc("/swagger.json"))
|
||||
|
||||
/* API */
|
||||
// Root Routes returns all top-level routes in the API
|
||||
router.GET("/chronograf/v1/", AllRoutes(opts.Logger))
|
||||
|
||||
// Sources
|
||||
router.GET("/chronograf/v1/sources", service.Sources)
|
||||
router.POST("/chronograf/v1/sources", service.NewSource)
|
||||
|
@ -120,45 +117,62 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
|
|||
router.PUT("/chronograf/v1/dashboards/:id", service.ReplaceDashboard)
|
||||
router.PATCH("/chronograf/v1/dashboards/:id", service.UpdateDashboard)
|
||||
|
||||
var authRoutes AuthRoutes
|
||||
|
||||
var out http.Handler
|
||||
/* Authentication */
|
||||
if opts.UseAuth {
|
||||
auth := AuthAPI(opts, router)
|
||||
return Logger(opts.Logger, auth)
|
||||
// Encapsulate the router with OAuth2
|
||||
var auth http.Handler
|
||||
auth, authRoutes = AuthAPI(opts, router)
|
||||
|
||||
// Create middleware to redirect to the appropriate provider logout
|
||||
targetURL := "/"
|
||||
router.GET("/oauth/logout", Logout(targetURL, authRoutes))
|
||||
|
||||
out = Logger(opts.Logger, auth)
|
||||
} else {
|
||||
out = Logger(opts.Logger, router)
|
||||
}
|
||||
|
||||
logged := Logger(opts.Logger, router)
|
||||
return logged
|
||||
router.GET("/chronograf/v1/", AllRoutes(authRoutes, opts.Logger))
|
||||
router.GET("/chronograf/v1", AllRoutes(authRoutes, opts.Logger))
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// AuthAPI adds the OAuth routes if auth is enabled.
|
||||
func AuthAPI(opts MuxOpts, router *httprouter.Router) http.Handler {
|
||||
auth := jwt.NewJWT(opts.TokenSecret)
|
||||
// TODO: this function is not great. Would be good if providers added their routes.
|
||||
func AuthAPI(opts MuxOpts, router *httprouter.Router) (http.Handler, AuthRoutes) {
|
||||
auth := oauth2.NewJWT(opts.TokenSecret)
|
||||
routes := AuthRoutes{}
|
||||
for _, pf := range opts.ProviderFuncs {
|
||||
pf(func(p oauth2.Provider, m oauth2.Mux) {
|
||||
loginPath := fmt.Sprintf("%s/oauth/%s/login", opts.Basepath, strings.ToLower(p.Name()))
|
||||
logoutPath := fmt.Sprintf("%s/oauth/%s/logout", opts.Basepath, strings.ToLower(p.Name()))
|
||||
callbackPath := fmt.Sprintf("%s/oauth/%s/callback", opts.Basepath, strings.ToLower(p.Name()))
|
||||
router.Handler("GET", loginPath, m.Login())
|
||||
router.Handler("GET", logoutPath, m.Logout())
|
||||
router.Handler("GET", callbackPath, m.Callback())
|
||||
routes = append(routes, AuthRoute{
|
||||
Name: p.Name(),
|
||||
Label: strings.Title(p.Name()),
|
||||
Login: loginPath,
|
||||
Logout: logoutPath,
|
||||
Callback: callbackPath,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
successURL := "/"
|
||||
failureURL := "/login"
|
||||
gh := NewGithub(
|
||||
opts.GithubClientID,
|
||||
opts.GithubClientSecret,
|
||||
successURL,
|
||||
failureURL,
|
||||
opts.GithubOrgs,
|
||||
&auth,
|
||||
opts.Logger,
|
||||
)
|
||||
|
||||
router.GET("/oauth/github", gh.Login())
|
||||
router.GET("/oauth/logout", gh.Logout())
|
||||
router.GET("/oauth/github/callback", gh.Callback())
|
||||
|
||||
tokenMiddleware := AuthorizedToken(&auth, &CookieExtractor{Name: "session"}, opts.Logger, router)
|
||||
tokenMiddleware := oauth2.AuthorizedToken(&auth, &oauth2.CookieExtractor{Name: "session"}, 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/") {
|
||||
if strings.HasPrefix(r.URL.Path, "/chronograf/v1/") || r.URL.Path == "/oauth/logout" {
|
||||
tokenMiddleware.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
router.ServeHTTP(w, r)
|
||||
})
|
||||
}), routes
|
||||
}
|
||||
|
||||
func encodeJSON(w http.ResponseWriter, status int, v interface{}, logger chronograf.Logger) {
|
||||
|
|
|
@ -6,17 +6,40 @@ import (
|
|||
"github.com/influxdata/chronograf"
|
||||
)
|
||||
|
||||
// AuthRoute are the routes for each type of OAuth2 provider
|
||||
type AuthRoute struct {
|
||||
Name string `json:"name"` // Name uniquely identifies the provider
|
||||
Label string `json:"label"` // Label is a user-facing string to present in the UI
|
||||
Login string `json:"login"` // Login is the route to the login redirect path
|
||||
Logout string `json:"logout"` // Logout is the route to the logout redirect path
|
||||
Callback string `json:"callback"` // Callback is the route the provider calls to exchange the code/state
|
||||
}
|
||||
|
||||
// AuthRoutes contains all OAuth2 provider routes.
|
||||
type AuthRoutes []AuthRoute
|
||||
|
||||
// Lookup searches all the routes for a specific provider
|
||||
func (r *AuthRoutes) Lookup(provider string) (AuthRoute, bool) {
|
||||
for _, route := range *r {
|
||||
if route.Name == provider {
|
||||
return route, true
|
||||
}
|
||||
}
|
||||
return AuthRoute{}, false
|
||||
}
|
||||
|
||||
type getRoutesResponse struct {
|
||||
Layouts string `json:"layouts"` // Location of the layouts endpoint
|
||||
Mappings string `json:"mappings"` // Location of the application mappings endpoint
|
||||
Sources string `json:"sources"` // Location of the sources endpoint
|
||||
Users string `json:"users"` // Location of the users endpoint
|
||||
Me string `json:"me"` // Location of the me endpoint
|
||||
Dashboards string `json:"dashboards"` // Location of the dashboards endpoint
|
||||
Layouts string `json:"layouts"` // Location of the layouts endpoint
|
||||
Mappings string `json:"mappings"` // Location of the application mappings endpoint
|
||||
Sources string `json:"sources"` // Location of the sources endpoint
|
||||
Users string `json:"users"` // Location of the users endpoint
|
||||
Me string `json:"me"` // Location of the me endpoint
|
||||
Dashboards string `json:"dashboards"` // Location of the dashboards endpoint
|
||||
Auth []AuthRoute `json:"auth"` // Location of all auth routes.
|
||||
}
|
||||
|
||||
// AllRoutes returns all top level routes within chronograf
|
||||
func AllRoutes(logger chronograf.Logger) http.HandlerFunc {
|
||||
func AllRoutes(authRoutes []AuthRoute, logger chronograf.Logger) http.HandlerFunc {
|
||||
routes := getRoutesResponse{
|
||||
Sources: "/chronograf/v1/sources",
|
||||
Layouts: "/chronograf/v1/layouts",
|
||||
|
@ -24,6 +47,11 @@ func AllRoutes(logger chronograf.Logger) http.HandlerFunc {
|
|||
Me: "/chronograf/v1/me",
|
||||
Mappings: "/chronograf/v1/mappings",
|
||||
Dashboards: "/chronograf/v1/dashboards",
|
||||
Auth: make([]AuthRoute, len(authRoutes)),
|
||||
}
|
||||
|
||||
for i, route := range authRoutes {
|
||||
routes.Auth[i] = route
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -31,3 +59,33 @@ func AllRoutes(logger chronograf.Logger) http.HandlerFunc {
|
|||
return
|
||||
})
|
||||
}
|
||||
|
||||
func NewGithubRoute() AuthRoute {
|
||||
return AuthRoute{
|
||||
Name: "github",
|
||||
Label: "GitHub",
|
||||
Login: "/oauth/github/login",
|
||||
Logout: "/oauth/github/logout",
|
||||
Callback: "/oauth/github/callback",
|
||||
}
|
||||
}
|
||||
|
||||
func NewGoogleRoute() AuthRoute {
|
||||
return AuthRoute{
|
||||
Name: "google",
|
||||
Label: "Google",
|
||||
Login: "/oauth/google/login",
|
||||
Logout: "/oauth/google/logout",
|
||||
Callback: "/oauth/google/callback",
|
||||
}
|
||||
}
|
||||
|
||||
func NewHerokuRoute() AuthRoute {
|
||||
return AuthRoute{
|
||||
Name: "heroku",
|
||||
Label: "Heroku",
|
||||
Login: "/oauth/heroku/login",
|
||||
Logout: "/oauth/heroku/logout",
|
||||
Callback: "/oauth/heroku/callback",
|
||||
}
|
||||
}
|
||||
|
|
115
server/server.go
115
server/server.go
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/influxdata/chronograf/influx"
|
||||
"github.com/influxdata/chronograf/layouts"
|
||||
clog "github.com/influxdata/chronograf/log"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
"github.com/influxdata/chronograf/uuid"
|
||||
client "github.com/influxdata/usage-client/v1"
|
||||
flags "github.com/jessevdk/go-flags"
|
||||
|
@ -38,20 +39,88 @@ type Server struct {
|
|||
Cert flags.Filename `long:"cert" description:"Path to PEM encoded public key certificate. " env:"TLS_CERTIFICATE"`
|
||||
Key flags.Filename `long:"key" description:"Path to private key associated with given certificate. " env:"TLS_PRIVATE_KEY"`
|
||||
|
||||
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"`
|
||||
|
||||
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"`
|
||||
GithubOrgs []string `short:"o" long:"github-organization" description:"Github organization user is required to have active membership" env:"GH_ORGS" env-delim:","`
|
||||
ReportingDisabled bool `short:"r" long:"reporting-disabled" description:"Disable reporting of usage stats (os,arch,version,cluster_id,uptime) once every 24hr" env:"REPORTING_DISABLED"`
|
||||
LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"fatal" choice:"panic" default:"info" description:"Set the logging level" env:"LOG_LEVEL"`
|
||||
Basepath string `short:"p" long:"basepath" description:"A URL path prefix under which all chronograf routes will be mounted" env:"BASE_PATH"`
|
||||
ShowVersion bool `short:"v" long:"version" description:"Show Chronograf version info"`
|
||||
BuildInfo BuildInfo
|
||||
Listener net.Listener
|
||||
handler http.Handler
|
||||
|
||||
GoogleClientID string `long:"google-client-id" description:"Google Client ID for OAuth 2 support" env:"GOOGLE_CLIENT_ID"`
|
||||
GoogleClientSecret string `long:"google-client-secret" description:"Google Client Secret for OAuth 2 support" env:"GOGGLE_CLIENT_SECRET"`
|
||||
GoogleDomains []string `long:"google-domains" description:"Google email domain user is required to have active membership" env:"GOOGLE_DOMAINS" env-delim:","`
|
||||
PublicURL string `long:"public-url" description:"Full public URL used to access Chronograf from a web browser. Used for Google OAuth2 authentication. (http://localhost:8888)" env:"PUBLIC_URL"`
|
||||
|
||||
HerokuClientID string `long:"heroku-client-id" description:"Heroku Client ID for OAuth 2 support" env:"HEROKU_CLIENT_ID"`
|
||||
HerokuSecret string `long:"heroku-secret" description:"Heroku Secret for OAuth 2 support" env:"HEROKU_SECRET"`
|
||||
HerokuOrganizations []string `long:"heroku-organization" description:"Heroku Organization Memberships a user is required to have for access to Chronograf (comma separated)" env:"HEROKU_ORGS" env-delim:","`
|
||||
|
||||
ReportingDisabled bool `short:"r" long:"reporting-disabled" description:"Disable reporting of usage stats (os,arch,version,cluster_id,uptime) once every 24hr" env:"REPORTING_DISABLED"`
|
||||
LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"fatal" choice:"panic" default:"info" description:"Set the logging level" env:"LOG_LEVEL"`
|
||||
Basepath string `short:"p" long:"basepath" description:"A URL path prefix under which all chronograf routes will be mounted" env:"BASE_PATH"`
|
||||
ShowVersion bool `short:"v" long:"version" description:"Show Chronograf version info"`
|
||||
BuildInfo BuildInfo
|
||||
Listener net.Listener
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func provide(p oauth2.Provider, m oauth2.Mux, ok func() bool) func(func(oauth2.Provider, oauth2.Mux)) {
|
||||
return func(configure func(oauth2.Provider, oauth2.Mux)) {
|
||||
if ok() {
|
||||
configure(p, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) UseGithub() bool {
|
||||
return s.TokenSecret != "" && s.GithubClientID != "" && s.GithubClientSecret != ""
|
||||
}
|
||||
|
||||
func (s *Server) UseGoogle() bool {
|
||||
return s.TokenSecret != "" && s.GoogleClientID != "" && s.GoogleClientSecret != "" && s.PublicURL != ""
|
||||
}
|
||||
|
||||
func (s *Server) UseHeroku() bool {
|
||||
return s.TokenSecret != "" && s.HerokuClientID != "" && s.HerokuSecret != ""
|
||||
}
|
||||
|
||||
func (s *Server) githubOAuth(logger chronograf.Logger, auth oauth2.Authenticator) (oauth2.Provider, oauth2.Mux, func() bool) {
|
||||
gh := oauth2.Github{
|
||||
ClientID: s.GithubClientID,
|
||||
ClientSecret: s.GithubClientSecret,
|
||||
Orgs: s.GithubOrgs,
|
||||
Logger: logger,
|
||||
}
|
||||
ghMux := oauth2.NewCookieMux(&gh, auth, logger)
|
||||
return &gh, ghMux, s.UseGithub
|
||||
}
|
||||
|
||||
func (s *Server) googleOAuth(logger chronograf.Logger, auth oauth2.Authenticator) (oauth2.Provider, oauth2.Mux, func() bool) {
|
||||
redirectURL := s.PublicURL + s.Basepath + "/oauth/google/callback"
|
||||
google := oauth2.Google{
|
||||
ClientID: s.GoogleClientID,
|
||||
ClientSecret: s.GoogleClientSecret,
|
||||
Domains: s.GoogleDomains,
|
||||
RedirectURL: redirectURL,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
goMux := oauth2.NewCookieMux(&google, auth, logger)
|
||||
return &google, goMux, s.UseGoogle
|
||||
}
|
||||
|
||||
func (s *Server) herokuOAuth(logger chronograf.Logger, auth oauth2.Authenticator) (oauth2.Provider, oauth2.Mux, func() bool) {
|
||||
heroku := oauth2.Heroku{
|
||||
ClientID: s.HerokuClientID,
|
||||
ClientSecret: s.HerokuSecret,
|
||||
Organizations: s.HerokuOrganizations,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
hMux := oauth2.NewCookieMux(&heroku, auth, logger)
|
||||
return &heroku, hMux, s.UseHeroku
|
||||
}
|
||||
|
||||
// BuildInfo is sent to the usage client to track versions and commits
|
||||
|
@ -61,7 +130,10 @@ type BuildInfo struct {
|
|||
}
|
||||
|
||||
func (s *Server) useAuth() bool {
|
||||
return s.TokenSecret != "" && s.GithubClientID != "" && s.GithubClientSecret != ""
|
||||
gh := s.TokenSecret != "" && s.GithubClientID != "" && s.GithubClientSecret != ""
|
||||
google := s.TokenSecret != "" && s.GoogleClientID != "" && s.GoogleClientSecret != "" && s.PublicURL != ""
|
||||
heroku := s.TokenSecret != "" && s.HerokuClientID != "" && s.HerokuSecret != ""
|
||||
return gh || google || heroku
|
||||
}
|
||||
|
||||
func (s *Server) useTLS() bool {
|
||||
|
@ -105,14 +177,19 @@ func (s *Server) Serve() error {
|
|||
service := openService(s.BoltPath, s.CannedPath, logger, s.useAuth())
|
||||
basepath = s.Basepath
|
||||
|
||||
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)))
|
||||
|
||||
s.handler = NewMux(MuxOpts{
|
||||
Develop: s.Develop,
|
||||
TokenSecret: s.TokenSecret,
|
||||
GithubClientID: s.GithubClientID,
|
||||
GithubClientSecret: s.GithubClientSecret,
|
||||
GithubOrgs: s.GithubOrgs,
|
||||
Logger: logger,
|
||||
UseAuth: s.useAuth(),
|
||||
Develop: s.Develop,
|
||||
TokenSecret: s.TokenSecret,
|
||||
Logger: logger,
|
||||
UseAuth: s.useAuth(),
|
||||
ProviderFuncs: providerFuncs,
|
||||
}, service)
|
||||
|
||||
// Add chronograf's version header to all requests
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"golang.org/x/net/context"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
type userLinks struct {
|
||||
|
@ -19,14 +20,19 @@ type userResponse struct {
|
|||
Links userLinks `json:"links"`
|
||||
}
|
||||
|
||||
// If new user response is nil, return an empty userResponse because it
|
||||
// indicates authentication is not needed
|
||||
func newUserResponse(usr *chronograf.User) userResponse {
|
||||
base := "/chronograf/v1/users"
|
||||
return userResponse{
|
||||
User: usr,
|
||||
Links: userLinks{
|
||||
Self: fmt.Sprintf("%s/%d", base, usr.ID),
|
||||
},
|
||||
if usr != nil {
|
||||
return userResponse{
|
||||
User: usr,
|
||||
Links: userLinks{
|
||||
Self: fmt.Sprintf("%s/%d", base, usr.ID),
|
||||
},
|
||||
}
|
||||
}
|
||||
return userResponse{}
|
||||
}
|
||||
|
||||
// NewUser adds a new valid user to the store
|
||||
|
@ -135,19 +141,32 @@ func ValidUserRequest(s *chronograf.User) error {
|
|||
}
|
||||
|
||||
func getEmail(ctx context.Context) (string, error) {
|
||||
principal := ctx.Value(chronograf.PrincipalKey).(chronograf.Principal)
|
||||
if principal == "" {
|
||||
principal, err := getPrincipal(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if principal.Subject == "" {
|
||||
return "", fmt.Errorf("Token not found")
|
||||
}
|
||||
return string(principal), nil
|
||||
return principal.Subject, nil
|
||||
}
|
||||
|
||||
func getPrincipal(ctx context.Context) (oauth2.Principal, error) {
|
||||
principal, ok := ctx.Value(oauth2.PrincipalKey).(oauth2.Principal)
|
||||
if !ok {
|
||||
return oauth2.Principal{}, fmt.Errorf("Token not found")
|
||||
}
|
||||
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
// Me does a findOrCreate based on the email in the context
|
||||
func (h *Service) Me(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !h.UseAuth {
|
||||
// Using status code to signal no need for authentication
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
// If there's no authentication, return an empty user
|
||||
res := newUserResponse(nil)
|
||||
encodeJSON(w, http.StatusOK, res, h.Logger)
|
||||
return
|
||||
}
|
||||
email, err := getEmail(ctx)
|
||||
|
|
|
@ -182,7 +182,7 @@
|
|||
'quote-props': [2, 'as-needed', {keywords: true, numbers: false }],
|
||||
'require-jsdoc': 0,
|
||||
'semi-spacing': [2, {before: false, after: true}],
|
||||
'semi': [0, 'always'],
|
||||
// 'semi': [2, 'always'],
|
||||
'sort-vars': 0,
|
||||
'keyword-spacing': 'error',
|
||||
'space-before-blocks': [2, 'always'],
|
||||
|
|
|
@ -1,21 +1,33 @@
|
|||
import React from 'react';
|
||||
import {withRouter} from 'react-router';
|
||||
/* global VERSION */
|
||||
import React, {PropTypes} from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
|
||||
const Login = React.createClass({
|
||||
render() {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-box">
|
||||
<div className="auth-logo"></div>
|
||||
<h1 className="auth-text-logo">Chronograf</h1>
|
||||
<p><strong>v1.1</strong> / Time-Series Data Visualization</p>
|
||||
<a className="btn btn-primary" href="/oauth/github"><span className="icon github"></span> Login with GitHub</a>
|
||||
</div>
|
||||
<p className="auth-credits">Made by <span className="icon cubo-uniform"></span>InfluxData</p>
|
||||
<div className="auth-image"></div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
const {array} = PropTypes
|
||||
|
||||
export default withRouter(Login);
|
||||
const Login = ({auth}) => (
|
||||
<div className="auth-page">
|
||||
<div className="auth-box">
|
||||
<div className="auth-logo"></div>
|
||||
<h1 className="auth-text-logo">Chronograf</h1>
|
||||
<p><strong>{VERSION}</strong> / Time-Series Data Visualization</p>
|
||||
{auth.map(({name, login, label}) => (
|
||||
<a key={name} className="btn btn-primary" href={login}>
|
||||
<span className={`icon ${name}`}></span>
|
||||
Login with {label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<p className="auth-credits">Made by <span className="icon cubo-uniform"></span>InfluxData</p>
|
||||
<div className="auth-image"></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Login.propTypes = {
|
||||
auth: array.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
auth: state.auth,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(Login)
|
||||
|
|
|
@ -3,7 +3,7 @@ import AJAX from 'utils/ajax';
|
|||
export function getDashboards() {
|
||||
return AJAX({
|
||||
method: 'GET',
|
||||
url: `/chronograf/v1/dashboards`,
|
||||
resource: 'dashboards',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -87,7 +87,7 @@ export async function getAllHosts(proxyLink, telegrafDB) {
|
|||
export function getMappings() {
|
||||
return AJAX({
|
||||
method: 'GET',
|
||||
url: `/chronograf/v1/mappings`,
|
||||
resource: 'mappings',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ import NotFound from 'src/shared/components/NotFound';
|
|||
import configureStore from 'src/store/configureStore';
|
||||
import {getMe, getSources} from 'shared/apis';
|
||||
import {receiveMe} from 'shared/actions/me';
|
||||
import {receiveAuth} from 'shared/actions/auth';
|
||||
import {disablePresentationMode} from 'shared/actions/ui';
|
||||
import {loadLocalStorage} from './localStorage';
|
||||
|
||||
|
@ -76,14 +77,13 @@ const Root = React.createClass({
|
|||
if (store.getState().me.links) {
|
||||
return this.setState({loggedIn: true});
|
||||
}
|
||||
getMe().then(({data: me}) => {
|
||||
getMe().then(({data: me, auth}) => {
|
||||
store.dispatch(receiveMe(me));
|
||||
store.dispatch(receiveAuth(auth));
|
||||
this.setState({loggedIn: true});
|
||||
}).catch((err) => {
|
||||
const AUTH_DISABLED = 418;
|
||||
if (err.response.status === AUTH_DISABLED) {
|
||||
return this.setState({loggedIn: true});
|
||||
// Could store a boolean indicating auth is not set up
|
||||
}).catch((error) => {
|
||||
if (error.auth) {
|
||||
store.dispatch(receiveAuth(error.auth));
|
||||
}
|
||||
|
||||
this.setState({loggedIn: false});
|
||||
|
@ -128,8 +128,8 @@ const Root = React.createClass({
|
|||
<Route path="alert-rules/:ruleID" component={KapacitorRulePage} />
|
||||
<Route path="alert-rules/new" component={KapacitorRulePage} />
|
||||
</Route>
|
||||
<Route path="*" component={NotFound} />
|
||||
</Route>
|
||||
<Route path="*" component={NotFound} />
|
||||
</Router>
|
||||
</Provider>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
export function receiveAuth(auth) {
|
||||
return {
|
||||
type: 'AUTH_RECEIVED',
|
||||
payload: {
|
||||
auth,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -4,31 +4,33 @@ export function fetchLayouts() {
|
|||
return AJAX({
|
||||
url: `/chronograf/v1/layouts`,
|
||||
method: 'GET',
|
||||
resource: 'layouts',
|
||||
});
|
||||
}
|
||||
|
||||
export function getMe() {
|
||||
return AJAX({
|
||||
url: `/chronograf/v1/me`,
|
||||
resource: 'me',
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
export function getSources() {
|
||||
return AJAX({
|
||||
url: '/chronograf/v1/sources',
|
||||
resource: 'sources',
|
||||
});
|
||||
}
|
||||
|
||||
export function getSource(sourceID) {
|
||||
export function getSource(id) {
|
||||
return AJAX({
|
||||
url: `/chronograf/v1/sources/${sourceID}`,
|
||||
resource: 'sources',
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSource(attributes) {
|
||||
return AJAX({
|
||||
url: '/chronograf/v1/sources',
|
||||
resource: 'sources',
|
||||
method: 'POST',
|
||||
data: attributes,
|
||||
});
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
function getInitialState() {
|
||||
return [];
|
||||
}
|
||||
const initialState = getInitialState();
|
||||
|
||||
export default function auth(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case 'AUTH_RECEIVED': {
|
||||
return action.payload.auth;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
|
@ -1,11 +1,13 @@
|
|||
import appUI from './ui';
|
||||
import me from './me';
|
||||
import auth from './auth';
|
||||
import notifications from './notifications';
|
||||
import sources from './sources';
|
||||
|
||||
export {
|
||||
appUI,
|
||||
me,
|
||||
auth,
|
||||
notifications,
|
||||
sources,
|
||||
};
|
||||
|
|
|
@ -1,20 +1,56 @@
|
|||
import axios from 'axios';
|
||||
|
||||
export default function AJAX({
|
||||
let links
|
||||
|
||||
const UNAUTHORIZED = 401
|
||||
|
||||
export default async function AJAX({
|
||||
url,
|
||||
resource,
|
||||
id,
|
||||
method = 'GET',
|
||||
data = {},
|
||||
params = {},
|
||||
headers = {},
|
||||
}) {
|
||||
if (window.basepath) {
|
||||
url = `${window.basepath}${url}`;
|
||||
let response
|
||||
|
||||
try {
|
||||
const basepath = window.basepath || ''
|
||||
|
||||
url = `${basepath}${url}`
|
||||
|
||||
if (!links) {
|
||||
const linksRes = response = await axios({
|
||||
url: `${basepath}/chronograf/v1`,
|
||||
method: 'GET',
|
||||
})
|
||||
links = linksRes.data
|
||||
}
|
||||
|
||||
const {auth} = links
|
||||
|
||||
if (resource) {
|
||||
url = id ? `${basepath}${links[resource]}/${id}` : `${basepath}${links[resource]}`
|
||||
}
|
||||
|
||||
response = await axios({
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
params,
|
||||
headers,
|
||||
})
|
||||
|
||||
return {
|
||||
auth,
|
||||
...response,
|
||||
}
|
||||
} catch (error) {
|
||||
if (!response.status === UNAUTHORIZED) {
|
||||
console.error(error) // eslint-disable-line no-console
|
||||
}
|
||||
const {auth} = links
|
||||
throw {auth, ...response} // eslint-disable-line no-throw-literal
|
||||
}
|
||||
return axios({
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
params,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -81,6 +81,9 @@ module.exports = {
|
|||
new webpack.optimize.CommonsChunkPlugin({
|
||||
names: ['vendor', 'manifest'],
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
VERSION: JSON.stringify(require('../package.json').version),
|
||||
}),
|
||||
],
|
||||
postcss: require('./postcss'),
|
||||
target: 'web',
|
||||
|
|
|
@ -101,7 +101,10 @@ var config = {
|
|||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
new webpack.DefinePlugin({
|
||||
VERSION: JSON.stringify(require('../package.json').version),
|
||||
}),
|
||||
],
|
||||
postcss: require('./postcss'),
|
||||
target: 'web',
|
||||
|
|
35
uuid/v4.go
35
uuid/v4.go
|
@ -1,12 +1,6 @@
|
|||
package uuid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
)
|
||||
import uuid "github.com/satori/go.uuid"
|
||||
|
||||
// V4 implements chronograf.ID
|
||||
type V4 struct{}
|
||||
|
@ -15,30 +9,3 @@ type V4 struct{}
|
|||
func (i *V4) Generate() (string, error) {
|
||||
return uuid.NewV4().String(), nil
|
||||
}
|
||||
|
||||
// APIKey implements chronograf.Authenticator using V4
|
||||
type APIKey struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
// NewAPIKey creates an APIKey with a UUID v4 Key
|
||||
func NewAPIKey() chronograf.Authenticator {
|
||||
v4 := V4{}
|
||||
key, _ := v4.Generate()
|
||||
return &APIKey{
|
||||
Key: key,
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate checks the key against the UUID v4 key
|
||||
func (k *APIKey) Authenticate(ctx context.Context, key string) (chronograf.Principal, error) {
|
||||
if key != k.Key {
|
||||
return "", chronograf.ErrAuthentication
|
||||
}
|
||||
return "admin", nil
|
||||
}
|
||||
|
||||
// Token returns the UUID v4 key
|
||||
func (k *APIKey) Token(context.Context, chronograf.Principal, time.Duration) (string, error) {
|
||||
return k.Key, nil
|
||||
}
|
||||
|
|
|
@ -1,48 +0,0 @@
|
|||
package uuid_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
"github.com/influxdata/chronograf/uuid"
|
||||
)
|
||||
|
||||
func TestAuthenticate(t *testing.T) {
|
||||
var tests = []struct {
|
||||
Desc string
|
||||
APIKey string
|
||||
Key string
|
||||
Err error
|
||||
User chronograf.Principal
|
||||
}{
|
||||
|
||||
{
|
||||
Desc: "Test auth err when keys are different",
|
||||
APIKey: "key",
|
||||
Key: "badkey",
|
||||
Err: chronograf.ErrAuthentication,
|
||||
User: "",
|
||||
},
|
||||
{
|
||||
Desc: "Test that admin user comes back",
|
||||
APIKey: "key",
|
||||
Key: "key",
|
||||
Err: nil,
|
||||
User: "admin",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
k := uuid.APIKey{
|
||||
Key: test.APIKey,
|
||||
}
|
||||
u, err := k.Authenticate(context.Background(), test.Key)
|
||||
if err != test.Err {
|
||||
t.Errorf("Auth error different; expected %v actual %v", test.Err, err)
|
||||
}
|
||||
if u != test.User {
|
||||
t.Errorf("Auth user different; expected %v actual %v", test.User, u)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue