Merge pull request #922 from influxdata/feature/oauth-google

Support Multiple OAuth2 Providers - Add Heroku and Google
pull/931/head
Chris Goller 2017-02-23 17:34:01 -06:00 committed by GitHub
commit 2ee0008f42
43 changed files with 1822 additions and 629 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

140
oauth2/doc.go Normal file
View File

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

162
oauth2/github.go Normal file
View File

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

113
oauth2/github_test.go Normal file
View File

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

89
oauth2/google.go Normal file
View File

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

102
oauth2/google_test.go Normal file
View File

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

101
oauth2/heroku.go Normal file
View File

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

102
oauth2/heroku_test.go Normal file
View File

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

View File

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

View File

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

169
oauth2/mux.go Normal file
View File

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

157
oauth2/mux_test.go Normal file
View File

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

72
oauth2/oauth2.go Normal file
View File

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

100
oauth2/oauth2_test.go Normal file
View File

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

View File

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

21
server/logout.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import AJAX from 'utils/ajax';
export function getDashboards() {
return AJAX({
method: 'GET',
url: `/chronograf/v1/dashboards`,
resource: 'dashboards',
});
}

View File

@ -87,7 +87,7 @@ export async function getAllHosts(proxyLink, telegrafDB) {
export function getMappings() {
return AJAX({
method: 'GET',
url: `/chronograf/v1/mappings`,
resource: 'mappings',
});
}

View File

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

View File

@ -0,0 +1,8 @@
export function receiveAuth(auth) {
return {
type: 'AUTH_RECEIVED',
payload: {
auth,
},
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -101,7 +101,10 @@ var config = {
process.exit(1);
}
});
}
},
new webpack.DefinePlugin({
VERSION: JSON.stringify(require('../package.json').version),
}),
],
postcss: require('./postcss'),
target: 'web',

View File

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

View File

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