Reorganize OAuth2 Logic
Created an oauth2 package which encapsulates all oauth2 providers, utility functions, types, and interfaces. Previously some methods of the Github provider were used as http.HandlerFuncs. These have now been pulled into a concrete type called a JWTMux to implement other Oauth2 providers. JWTMux has all of the functionality required to take a token from any provider and store it as a JWT in a browser, and that is the extent of its responsibilities. It implements the oauth2.Mux interface which would potentially allow other strategies of oauth2 credential storage.pull/922/head
parent
f0e8d0b3e8
commit
d07c7ca1d6
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
)
|
||||
|
||||
// CookieExtractor extracts the token from the value of the Name cookie.
|
||||
type CookieExtractor struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// Extract returns the value of cookie Name
|
||||
func (c *CookieExtractor) Extract(r *http.Request) (string, error) {
|
||||
cookie, err := r.Cookie(c.Name)
|
||||
if err != nil {
|
||||
return "", ErrAuthentication
|
||||
}
|
||||
return cookie.Value, nil
|
||||
}
|
||||
|
||||
// BearerExtractor extracts the token from Authorization: Bearer header.
|
||||
type BearerExtractor struct{}
|
||||
|
||||
// Extract returns the string following Authorization: Bearer
|
||||
func (b *BearerExtractor) Extract(r *http.Request) (string, error) {
|
||||
s := r.Header.Get("Authorization")
|
||||
if s == "" {
|
||||
return "", ErrAuthentication
|
||||
}
|
||||
|
||||
// Check for Bearer token.
|
||||
strs := strings.Split(s, " ")
|
||||
|
||||
if len(strs) != 2 || strs[0] != "Bearer" {
|
||||
return "", ErrAuthentication
|
||||
}
|
||||
return strs[1], nil
|
||||
}
|
||||
|
||||
// AuthorizedToken extracts the token and validates; if valid the next handler
|
||||
// will be run. The principal will be sent to the next handler via the request's
|
||||
// Context. It is up to the next handler to determine if the principal has access.
|
||||
// On failure, will return http.StatusUnauthorized.
|
||||
func AuthorizedToken(auth Authenticator, te TokenExtractor, logger chronograf.Logger, next http.Handler) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log := logger.
|
||||
WithField("component", "auth").
|
||||
WithField("remote_addr", r.RemoteAddr).
|
||||
WithField("method", r.Method).
|
||||
WithField("url", r.URL)
|
||||
|
||||
token, err := te.Extract(r)
|
||||
if err != nil {
|
||||
log.Error("Unable to extract token")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// We do not check the validity of the principal. Those
|
||||
// server further down the chain should do so.
|
||||
principal, err := auth.Authenticate(r.Context(), token)
|
||||
if err != nil {
|
||||
log.Error("Invalid token")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Send the principal to the next handler
|
||||
ctx := context.WithValue(r.Context(), PrincipalKey, principal)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
})
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package server_test
|
||||
package oauth2_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
@ -28,7 +28,7 @@ func TestCookieExtractor(t *testing.T) {
|
|||
Value: "reallyimportant",
|
||||
Lookup: "Doesntexist",
|
||||
Expected: "",
|
||||
Err: chronograf.ErrAuthentication,
|
||||
Err: ErrAuthentication,
|
||||
},
|
||||
{
|
||||
Desc: "Cookie token extracted",
|
||||
|
@ -46,7 +46,7 @@ func TestCookieExtractor(t *testing.T) {
|
|||
Value: test.Value,
|
||||
})
|
||||
|
||||
var e chronograf.TokenExtractor = &server.CookieExtractor{
|
||||
var e TokenExtractor = &server.CookieExtractor{
|
||||
Name: test.Lookup,
|
||||
}
|
||||
actual, err := e.Extract(req)
|
||||
|
@ -74,21 +74,21 @@ func TestBearerExtractor(t *testing.T) {
|
|||
Header: "Doesntexist",
|
||||
Value: "reallyimportant",
|
||||
Expected: "",
|
||||
Err: chronograf.ErrAuthentication,
|
||||
Err: ErrAuthentication,
|
||||
},
|
||||
{
|
||||
Desc: "Auth header doesn't have Bearer",
|
||||
Header: "Authorization",
|
||||
Value: "Bad Value",
|
||||
Expected: "",
|
||||
Err: chronograf.ErrAuthentication,
|
||||
Err: ErrAuthentication,
|
||||
},
|
||||
{
|
||||
Desc: "Auth header doesn't have Bearer token",
|
||||
Header: "Authorization",
|
||||
Value: "Bearer",
|
||||
Expected: "",
|
||||
Err: chronograf.ErrAuthentication,
|
||||
Err: ErrAuthentication,
|
||||
},
|
||||
{
|
||||
Desc: "Authorization Bearer token success",
|
||||
|
@ -102,7 +102,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 TokenExtractor = &server.BearerExtractor{}
|
||||
actual, err := e.Extract(req)
|
||||
if err != test.Err {
|
||||
t.Errorf("Bearer extract error; expected %v actual %v", test.Err, err)
|
||||
|
@ -123,15 +123,15 @@ func (m *MockExtractor) Extract(*http.Request) (string, error) {
|
|||
}
|
||||
|
||||
type MockAuthenticator struct {
|
||||
Principal chronograf.Principal
|
||||
Principal Principal
|
||||
Err error
|
||||
}
|
||||
|
||||
func (m *MockAuthenticator) Authenticate(context.Context, string) (chronograf.Principal, error) {
|
||||
func (m *MockAuthenticator) Authenticate(context.Context, string) (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, Principal, time.Duration) (string, error) {
|
||||
return "", m.Err
|
||||
}
|
||||
|
||||
|
@ -139,7 +139,7 @@ func TestAuthorizedToken(t *testing.T) {
|
|||
var tests = []struct {
|
||||
Desc string
|
||||
Code int
|
||||
Principal chronograf.Principal
|
||||
Principal Principal
|
||||
ExtractorErr error
|
||||
AuthErr error
|
||||
Expected string
|
||||
|
@ -164,10 +164,10 @@ func TestAuthorizedToken(t *testing.T) {
|
|||
for _, test := range tests {
|
||||
// next is a sentinel StatusOK and
|
||||
// principal recorder.
|
||||
var principal chronograf.Principal
|
||||
var principal 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(PrincipalKey).(Principal)
|
||||
})
|
||||
req, _ := http.NewRequest("GET", "", nil)
|
||||
w := httptest.NewRecorder()
|
|
@ -0,0 +1,32 @@
|
|||
// ┌─────────┐ ┌───────────┐ ┌────────┐
|
||||
// │ 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 / │ │
|
||||
// ◀───────────────────────────┤ │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
|
||||
package oauth2
|
|
@ -1,4 +1,4 @@
|
|||
package server
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
|
@ -6,7 +6,6 @@ import (
|
|||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-github/github"
|
||||
"github.com/influxdata/chronograf"
|
||||
|
@ -14,34 +13,13 @@ import (
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
||||
var _ OAuth2Provider = &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 {
|
||||
Auth chronograf.Authenticator
|
||||
Auth Authenticator
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Orgs []string // Optional github organization checking
|
||||
|
@ -69,7 +47,7 @@ func (g *Github) Scopes() []string {
|
|||
}
|
||||
|
||||
// NewGithub constructs a Github with default scopes.
|
||||
func NewGithub(clientID, clientSecret string, orgs []string, auth chronograf.Authenticator, log chronograf.Logger) Github {
|
||||
func NewGithub(clientID, clientSecret string, orgs []string, auth Authenticator, log chronograf.Logger) Github {
|
||||
scopes := []string{"user:email"}
|
||||
if len(orgs) > 0 {
|
||||
scopes = append(scopes, "read:org")
|
|
@ -1,7 +1,7 @@
|
|||
package server
|
||||
package oauth2
|
||||
|
||||
type Google struct {
|
||||
OAuth2Provider
|
||||
Provider
|
||||
Domains []string // Optional google email domain checking
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
@ -72,11 +71,11 @@ func (j *JWT) Authenticate(ctx context.Context, jwtToken string) (chronograf.Pri
|
|||
return "", fmt.Errorf("unable to convert claims to standard claims")
|
||||
}
|
||||
|
||||
return chronograf.Principal(claims.Subject), nil
|
||||
return Principal(claims.Subject), 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()
|
|
@ -1,4 +1,4 @@
|
|||
package jwt_test
|
||||
package oauth2_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
@ -15,7 +15,7 @@ func TestAuthenticate(t *testing.T) {
|
|||
Desc string
|
||||
Secret string
|
||||
Token string
|
||||
User chronograf.Principal
|
||||
User Principal
|
||||
Err error
|
||||
}{
|
||||
{
|
||||
|
@ -83,7 +83,7 @@ func TestToken(t *testing.T) {
|
|||
return time.Unix(-446774400, 0)
|
||||
},
|
||||
}
|
||||
if token, err := j.Token(context.Background(), chronograf.Principal("/chronograf/v1/users/1"), duration); err != nil {
|
||||
if token, err := j.Token(context.Background(), Principal("/chronograf/v1/users/1"), 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,160 @@
|
|||
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 JWTMux is an oauth2.Mux
|
||||
var _ Mux = &JWTMux{}
|
||||
|
||||
func NewJWTMux(p Provider, a Authenticator, l chronograf.Logger) *JWTMux {
|
||||
return &JWTMux{
|
||||
Provider: p,
|
||||
Auth: a,
|
||||
Logger: l,
|
||||
SuccessURL: "/",
|
||||
FailureURL: "/login",
|
||||
Now: time.Now,
|
||||
|
||||
cookie: cookie{
|
||||
Name: DefaultCookieName,
|
||||
Duration: DefaultCookieDuration,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// JWTMux services an Oauth2 interaction with a provider and browser and stores
|
||||
// the resultant token in the user's browser as a cookie encoded as a JWT. The
|
||||
// benefit of this is that the JWT's authenticity can be verified independently
|
||||
// by any Chronograf instance.
|
||||
type JWTMux 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 JWT with a random string as the state validation method.
|
||||
// JWTs are used because they can be validated without storing state.
|
||||
func (j *JWTMux) 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
|
||||
state, err := j.Auth.Token(r.Context(), Principal(csrf), 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. The
|
||||
// value of the cookie is 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 *JWTMux) 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
|
||||
}
|
||||
|
||||
// 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(), Principal(id), 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 *JWTMux) 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,65 @@
|
|||
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 Principal = "principal"
|
||||
)
|
||||
|
||||
var (
|
||||
/* Errors */
|
||||
ErrAuthentication = errors.New("user not authenticated")
|
||||
)
|
||||
|
||||
/* Types */
|
||||
|
||||
// Principal is any entity that can be authenticated
|
||||
type Principal 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
196
server/auth.go
196
server/auth.go
|
@ -1,196 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
)
|
||||
|
||||
// CookieExtractor extracts the token from the value of the Name cookie.
|
||||
type CookieExtractor struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// Extract returns the value of cookie Name
|
||||
func (c *CookieExtractor) Extract(r *http.Request) (string, error) {
|
||||
cookie, err := r.Cookie(c.Name)
|
||||
if err != nil {
|
||||
return "", chronograf.ErrAuthentication
|
||||
}
|
||||
return cookie.Value, nil
|
||||
}
|
||||
|
||||
// BearerExtractor extracts the token from Authorization: Bearer header.
|
||||
type BearerExtractor struct{}
|
||||
|
||||
// Extract returns the string following Authorization: Bearer
|
||||
func (b *BearerExtractor) Extract(r *http.Request) (string, error) {
|
||||
s := r.Header.Get("Authorization")
|
||||
if s == "" {
|
||||
return "", chronograf.ErrAuthentication
|
||||
}
|
||||
|
||||
// Check for Bearer token.
|
||||
strs := strings.Split(s, " ")
|
||||
|
||||
if len(strs) != 2 || strs[0] != "Bearer" {
|
||||
return "", chronograf.ErrAuthentication
|
||||
}
|
||||
return strs[1], nil
|
||||
}
|
||||
|
||||
// AuthorizedToken extracts the token and validates; if valid the next handler
|
||||
// will be run. The principal will be sent to the next handler via the request's
|
||||
// Context. It is up to the next handler to determine if the principal has access.
|
||||
// On failure, will return http.StatusUnauthorized.
|
||||
func AuthorizedToken(auth chronograf.Authenticator, te chronograf.TokenExtractor, logger chronograf.Logger, next http.Handler) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log := logger.
|
||||
WithField("component", "auth").
|
||||
WithField("remote_addr", r.RemoteAddr).
|
||||
WithField("method", r.Method).
|
||||
WithField("url", r.URL)
|
||||
|
||||
token, err := te.Extract(r)
|
||||
if err != nil {
|
||||
log.Error("Unable to extract token")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// We do not check the validity of the principal. Those
|
||||
// server further down the chain should do so.
|
||||
principal, err := auth.Authenticate(r.Context(), token)
|
||||
if err != nil {
|
||||
log.Error("Invalid token")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Send the principal to the next handler
|
||||
ctx := context.WithValue(r.Context(), chronograf.PrincipalKey, principal)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
// Login returns a handler that redirects to the providers 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 Login(provider OAuth2Provider, auth chronograf.Authenticator, logger chronograf.Logger) http.HandlerFunc {
|
||||
conf := 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
|
||||
state, err := auth.Token(r.Context(), chronograf.Principal(csrf), 10*time.Minute)
|
||||
// This is likely an internal server error
|
||||
if err != nil {
|
||||
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 handler will expire our authentication cookie and redirect to the successURL
|
||||
func Logout(cookie Cookie, successURL string, now func() time.Time) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteCookie := http.Cookie{
|
||||
Name: cookie.Name,
|
||||
Value: "none",
|
||||
Expires: now().UTC().Add(-1 * time.Hour),
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
}
|
||||
http.SetCookie(w, &deleteCookie)
|
||||
http.Redirect(w, r, successURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
}
|
||||
|
||||
// CallbackOpts are the options for the Callback handler
|
||||
type CallbackOpts struct {
|
||||
Provider OAuth2Provider
|
||||
Auth chronograf.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
|
||||
}
|
||||
|
||||
// Callback is used by OAuth2 provider 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 principal's identifier (e.g.
|
||||
// email address).
|
||||
func Callback(opts CallbackOpts) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log := opts.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 := opts.Auth.Authenticate(r.Context(), state)
|
||||
if err != nil {
|
||||
log.Error("Invalid OAuth state received: ", err.Error())
|
||||
http.Redirect(w, r, opts.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange the code back with the provider to the the token
|
||||
conf := opts.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, opts.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Using the token get the principal identifier from the provider
|
||||
oauthClient := conf.Client(r.Context(), token)
|
||||
id, err := opts.Provider.PrincipalID(oauthClient)
|
||||
if err != nil {
|
||||
log.Error("Unable to get principal identifier ", err.Error())
|
||||
http.Redirect(w, r, opts.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 := opts.Auth.Token(r.Context(), chronograf.Principal(id), opts.Cookie.Duration)
|
||||
if err != nil {
|
||||
log.Error("Unable to create cookie auth token ", err.Error())
|
||||
http.Redirect(w, r, opts.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
expireCookie := opts.Now().UTC().Add(opts.Cookie.Duration)
|
||||
cookie := http.Cookie{
|
||||
Name: opts.Cookie.Name,
|
||||
Value: authToken,
|
||||
Expires: expireCookie,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
}
|
||||
log.Info("User ", id, " is authenticated")
|
||||
http.SetCookie(w, &cookie)
|
||||
http.Redirect(w, r, opts.SuccessURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
}
|
|
@ -6,12 +6,11 @@ import (
|
|||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 (
|
||||
|
@ -133,9 +132,9 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
|
|||
|
||||
// AuthAPI adds the OAuth routes if auth is enabled.
|
||||
func AuthAPI(opts MuxOpts, router *httprouter.Router) http.Handler {
|
||||
auth := jwt.NewJWT(opts.TokenSecret)
|
||||
auth := oauth2.NewJWT(opts.TokenSecret)
|
||||
|
||||
gh := NewGithub(
|
||||
gh := oauth2.NewGithub(
|
||||
opts.GithubClientID,
|
||||
opts.GithubClientSecret,
|
||||
opts.GithubOrgs,
|
||||
|
@ -143,20 +142,12 @@ func AuthAPI(opts MuxOpts, router *httprouter.Router) http.Handler {
|
|||
opts.Logger,
|
||||
)
|
||||
|
||||
callback := CallbackOpts{
|
||||
Provider: &gh,
|
||||
Auth: &auth,
|
||||
Cookie: NewCookie(),
|
||||
Logger: opts.Logger,
|
||||
SuccessURL: "/",
|
||||
FailureURL: "/login",
|
||||
Now: time.Now,
|
||||
}
|
||||
router.GET("/oauth/github", gh.Login())
|
||||
router.GET("/oauth/logout", gh.Logout())
|
||||
router.GET("/oauth/github/callback", gh.Callback())
|
||||
ghMux := oauth2.NewJWTMux(&gh, &auth, opts.Logger)
|
||||
router.Handler("GET", "/oauth/github/login", ghMux.Login())
|
||||
router.Handler("GET", "/oauth/github/logout", ghMux.Logout())
|
||||
router.Handler("GET", "/oauth/github/callback", ghMux.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/") {
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// OAuth2Provider are the common parameters for all providers (RFC 6749)
|
||||
type OAuth2Provider 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)
|
||||
}
|
|
@ -8,6 +8,7 @@ import (
|
|||
"golang.org/x/net/context"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
type userLinks struct {
|
||||
|
@ -135,7 +136,7 @@ func ValidUserRequest(s *chronograf.User) error {
|
|||
}
|
||||
|
||||
func getEmail(ctx context.Context) (string, error) {
|
||||
principal := ctx.Value(chronograf.PrincipalKey).(chronograf.Principal)
|
||||
principal := ctx.Value(oauth2.PrincipalKey).(oauth2.Principal)
|
||||
if principal == "" {
|
||||
return "", fmt.Errorf("Token not found")
|
||||
}
|
||||
|
|
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