280 lines
8.6 KiB
Go
280 lines
8.6 KiB
Go
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")
|
|
}
|