pull/922/head
Chris Goller 2017-02-13 18:02:43 -06:00
parent ad2a3bc0d2
commit f1e7ae30c3
6 changed files with 187 additions and 143 deletions

View File

@ -4,6 +4,9 @@ import (
"context"
"net/http"
"strings"
"time"
"golang.org/x/oauth2"
"github.com/influxdata/chronograf"
)
@ -74,3 +77,120 @@ func AuthorizedToken(auth chronograf.Authenticator, te chronograf.TokenExtractor
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)
})
}

View File

@ -35,29 +35,31 @@ func NewCookie() Cookie {
}
}
var _ OAuth2Provider = &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 {
Cookie Cookie
Auth chronograf.Authenticator
ClientID string
ClientSecret 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
}
// 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 {
@ -66,12 +68,8 @@ func (g *Github) Scopes() []string {
return scopes
}
func (g *Github) Authenticator() chronograf.Authenticator {
return g.Auth
}
// 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 {
// NewGithub constructs a Github with default scopes.
func NewGithub(clientID, clientSecret string, orgs []string, auth chronograf.Authenticator, log chronograf.Logger) Github {
scopes := []string{"user:email"}
if len(orgs) > 0 {
scopes = append(scopes, "read:org")
@ -79,17 +77,14 @@ func NewGithub(clientID, clientSecret, successURL, failureURL string, orgs []str
return Github{
ClientID: clientID,
ClientSecret: clientSecret,
Cookie: NewCookie(),
Orgs: orgs,
SuccessURL: successURL,
FailureURL: failureURL,
Auth: auth,
Now: time.Now,
Logger: log,
}
}
func (g *Github) config() *oauth2.Config {
// Config is the Github OAuth2 exchange information and endpoints
func (g *Github) Config() *oauth2.Config {
return &oauth2.Config{
ClientID: g.ID(),
ClientSecret: g.Secret(),
@ -98,125 +93,28 @@ func (g *Github) config() *oauth2.Config {
}
}
// 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
// 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 {
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
return "", err
}
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: "/",
// 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
}
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 := g.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)
})
email, err := getPrimaryEmail(client, g.Logger)
if err != nil {
return "", nil
}
return email, nil
}
func randomString(length int) string {

View File

@ -1,6 +0,0 @@
package server
type Google struct {
OAuth2Provider
Domains []string // Optional google email domain checking
}

19
server/google.go Normal file
View File

@ -0,0 +1,19 @@
package server
type Google struct {
OAuth2Provider
Domains []string // Optional google email domain checking
}
/*
client := conf.Client(oauth2.NoContext, tok)
email, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo")
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
defer email.Body.Close()
data, _ := ioutil.ReadAll(email.Body)
log.Println("Email body: ", string(data))
c.Status(http.StatusOK)
*/

View File

@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/NYTimes/gziphandler"
"github.com/bouk/httprouter"
@ -57,6 +58,7 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
/* API */
// Root Routes returns all top-level routes in the API
router.GET("/chronograf/v1/", AllRoutes(opts.Logger))
router.GET("/chronograf/v1", AllRoutes(opts.Logger))
// Sources
router.GET("/chronograf/v1/sources", service.Sources)
@ -133,18 +135,23 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
func AuthAPI(opts MuxOpts, router *httprouter.Router) http.Handler {
auth := jwt.NewJWT(opts.TokenSecret)
successURL := "/"
failureURL := "/login"
gh := NewGithub(
opts.GithubClientID,
opts.GithubClientSecret,
successURL,
failureURL,
opts.GithubOrgs,
&auth,
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())

View File

@ -1,6 +1,10 @@
package server
import "github.com/influxdata/chronograf"
import (
"net/http"
"golang.org/x/oauth2"
)
// OAuth2Provider are the common parameters for all providers (RFC 6749)
type OAuth2Provider interface {
@ -10,6 +14,8 @@ type OAuth2Provider interface {
Secret() string
// Scopes is used by the authorization server to "scope" responses (Section 3.3)
Scopes() []string
// Authenticator generates and validates tokens
Authenticator() chronograf.Authenticator
// 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)
}