2017-04-06 15:19:07 +00:00
|
|
|
package oauth2
|
|
|
|
|
|
|
|
import (
|
2020-12-14 17:06:11 +00:00
|
|
|
"bytes"
|
2017-04-06 15:19:07 +00:00
|
|
|
"encoding/json"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
2020-12-14 17:06:11 +00:00
|
|
|
"io/ioutil"
|
2017-04-06 15:19:07 +00:00
|
|
|
"net/http"
|
|
|
|
"strings"
|
|
|
|
|
2021-08-04 05:36:49 +00:00
|
|
|
gojwt "github.com/golang-jwt/jwt/v4"
|
2017-04-06 15:19:07 +00:00
|
|
|
"github.com/influxdata/chronograf"
|
|
|
|
"golang.org/x/oauth2"
|
|
|
|
)
|
|
|
|
|
2018-02-20 08:47:42 +00:00
|
|
|
// ExtendedProvider extendts the base Provider interface with optional methods
|
2017-12-10 17:33:50 +00:00
|
|
|
type ExtendedProvider interface {
|
2017-12-13 08:13:11 +00:00
|
|
|
Provider
|
|
|
|
// get PrincipalID from id_token
|
|
|
|
PrincipalIDFromClaims(claims gojwt.MapClaims) (string, error)
|
2018-02-20 08:47:42 +00:00
|
|
|
GroupFromClaims(claims gojwt.MapClaims) (string, error)
|
2017-12-10 17:33:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
var _ ExtendedProvider = &Generic{}
|
2017-04-06 15:19:07 +00:00
|
|
|
|
|
|
|
// Generic provides OAuth Login and Callback server and is modeled
|
|
|
|
// after the Github OAuth2 provider. Callback will set an authentication
|
|
|
|
// cookie. This cookie's value is a JWT containing the user's primary
|
|
|
|
// email address.
|
|
|
|
type Generic struct {
|
2017-04-07 19:58:35 +00:00
|
|
|
PageName string // Name displayed on the login page
|
|
|
|
ClientID string
|
|
|
|
ClientSecret string
|
|
|
|
RequiredScopes []string
|
|
|
|
Domains []string // Optional email domain checking
|
2017-06-16 09:35:57 +00:00
|
|
|
RedirectURL string
|
2017-04-07 19:58:35 +00:00
|
|
|
AuthURL string
|
|
|
|
TokenURL string
|
|
|
|
APIURL string // APIURL returns OpenID Userinfo
|
2017-11-21 22:53:04 +00:00
|
|
|
APIKey string // APIKey is the JSON key to lookup email address in APIURL response
|
2017-04-07 19:58:35 +00:00
|
|
|
Logger chronograf.Logger
|
2017-04-06 15:19:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Name is the name of the provider
|
|
|
|
func (g *Generic) Name() string {
|
2017-04-06 21:45:13 +00:00
|
|
|
if g.PageName == "" {
|
|
|
|
return "generic"
|
|
|
|
}
|
|
|
|
return g.PageName
|
2017-04-06 15:19:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// ID returns the generic application client id
|
|
|
|
func (g *Generic) ID() string {
|
|
|
|
return g.ClientID
|
|
|
|
}
|
|
|
|
|
|
|
|
// Secret returns the generic application client secret
|
|
|
|
func (g *Generic) Secret() string {
|
|
|
|
return g.ClientSecret
|
|
|
|
}
|
|
|
|
|
2017-04-07 19:58:35 +00:00
|
|
|
// Scopes for generic provider required of the client.
|
2017-04-06 15:19:07 +00:00
|
|
|
func (g *Generic) Scopes() []string {
|
2017-04-07 19:58:35 +00:00
|
|
|
return g.RequiredScopes
|
2017-04-06 15:19:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Config is the Generic OAuth2 exchange information and endpoints
|
|
|
|
func (g *Generic) Config() *oauth2.Config {
|
|
|
|
return &oauth2.Config{
|
|
|
|
ClientID: g.ID(),
|
|
|
|
ClientSecret: g.Secret(),
|
|
|
|
Scopes: g.Scopes(),
|
2017-06-16 09:35:57 +00:00
|
|
|
RedirectURL: g.RedirectURL,
|
2017-04-06 15:19:07 +00:00
|
|
|
Endpoint: oauth2.Endpoint{
|
|
|
|
AuthURL: g.AuthURL,
|
|
|
|
TokenURL: g.TokenURL,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// PrincipalID returns the email address of the user.
|
|
|
|
func (g *Generic) PrincipalID(provider *http.Client) (string, error) {
|
2017-11-21 22:53:04 +00:00
|
|
|
res := map[string]interface{}{}
|
2017-04-06 15:19:07 +00:00
|
|
|
|
|
|
|
r, err := provider.Get(g.APIURL)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
defer r.Body.Close()
|
|
|
|
if err = json.NewDecoder(r.Body).Decode(&res); err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
2017-11-21 22:53:04 +00:00
|
|
|
email := ""
|
|
|
|
value := res[g.APIKey]
|
|
|
|
if e, ok := value.(string); ok {
|
|
|
|
email = e
|
|
|
|
}
|
2017-04-06 15:19:07 +00:00
|
|
|
|
|
|
|
// If we did not receive an email address, try to lookup the email
|
|
|
|
// in a similar way as github
|
|
|
|
if email == "" {
|
|
|
|
email, err = g.getPrimaryEmail(provider)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// If we need to restrict to a set of domains, we first get the org
|
|
|
|
// and filter.
|
|
|
|
if len(g.Domains) > 0 {
|
|
|
|
// If not in the domain deny permission
|
|
|
|
if ok := ofDomain(g.Domains, email); !ok {
|
|
|
|
msg := "Not a member of required domain"
|
|
|
|
g.Logger.Error(msg)
|
|
|
|
return "", fmt.Errorf(msg)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return email, nil
|
|
|
|
}
|
|
|
|
|
2018-02-20 08:47:42 +00:00
|
|
|
// Group returns the domain that a user belongs to in the
|
|
|
|
// the generic OAuth.
|
|
|
|
func (g *Generic) Group(provider *http.Client) (string, error) {
|
2018-02-22 15:40:22 +00:00
|
|
|
res := map[string]interface{}{}
|
2018-02-20 08:47:42 +00:00
|
|
|
|
|
|
|
r, err := provider.Get(g.APIURL)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
defer r.Body.Close()
|
|
|
|
if err = json.NewDecoder(r.Body).Decode(&res); err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
2018-02-22 15:40:22 +00:00
|
|
|
email := ""
|
|
|
|
value := res[g.APIKey]
|
|
|
|
if e, ok := value.(string); ok {
|
|
|
|
email = e
|
|
|
|
}
|
|
|
|
|
|
|
|
// If we did not receive an email address, try to lookup the email
|
|
|
|
// in a similar way as github
|
|
|
|
if email == "" {
|
|
|
|
email, err = g.getPrimaryEmail(provider)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
domain := strings.Split(email, "@")
|
|
|
|
if len(domain) != 2 {
|
|
|
|
return "", fmt.Errorf("malformed email address, expected %q to contain @ symbol", email)
|
2018-02-20 08:47:42 +00:00
|
|
|
}
|
|
|
|
|
2018-02-22 15:40:22 +00:00
|
|
|
return domain[1], nil
|
2018-02-20 08:47:42 +00:00
|
|
|
}
|
|
|
|
|
2017-04-06 15:19:07 +00:00
|
|
|
// UserEmail represents user's email address
|
|
|
|
type UserEmail struct {
|
|
|
|
Email *string `json:"email,omitempty"`
|
|
|
|
Primary *bool `json:"primary,omitempty"`
|
|
|
|
Verified *bool `json:"verified,omitempty"`
|
2020-12-14 17:06:11 +00:00
|
|
|
// support also indicators sent by bitbucket
|
|
|
|
IsPrimary *bool `json:"is_primary,omitempty"`
|
|
|
|
IsConfirmed *bool `json:"is_confirmed,omitempty"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// WrappedUserEmails represents (bitbucket's) structure that wraps email addresses in a values field
|
|
|
|
type WrappedUserEmails struct {
|
|
|
|
Emails []*UserEmail `json:"values,omitempty"`
|
2017-04-06 15:19:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// getPrimaryEmail gets the private email account for the authenticated user.
|
|
|
|
func (g *Generic) getPrimaryEmail(client *http.Client) (string, error) {
|
|
|
|
emailsEndpoint := g.APIURL + "/emails"
|
|
|
|
r, err := client.Get(emailsEndpoint)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
defer r.Body.Close()
|
2020-12-14 17:06:11 +00:00
|
|
|
body, err := ioutil.ReadAll(r.Body)
|
|
|
|
if err != nil {
|
2017-04-06 15:19:07 +00:00
|
|
|
return "", err
|
|
|
|
}
|
2020-12-14 17:06:11 +00:00
|
|
|
if len(body) == 0 {
|
|
|
|
return "", errors.New("No response body from /emails")
|
|
|
|
}
|
|
|
|
emails := []*UserEmail{}
|
|
|
|
if body[0] == '[' {
|
|
|
|
// array of UserEmail
|
|
|
|
if err = json.NewDecoder(bytes.NewReader(body)).Decode(&emails); err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
} else if body[0] == '{' {
|
|
|
|
// a struct with values that contain []*UserEmail{}
|
|
|
|
wrapped := WrappedUserEmails{}
|
|
|
|
if err = json.NewDecoder(bytes.NewReader(body)).Decode(&wrapped); err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
emails = wrapped.Emails
|
|
|
|
}
|
2017-04-06 15:19:07 +00:00
|
|
|
|
|
|
|
email, err := g.primaryEmail(emails)
|
|
|
|
if err != nil {
|
|
|
|
g.Logger.Error("Unable to retrieve primary email ", err.Error())
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
return email, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (g *Generic) primaryEmail(emails []*UserEmail) (string, error) {
|
2020-12-17 06:40:24 +00:00
|
|
|
var email string
|
2017-04-06 15:19:07 +00:00
|
|
|
for _, m := range emails {
|
2020-12-17 06:40:24 +00:00
|
|
|
if m != nil && m.Email != nil && ((m.Verified != nil) || (m.IsConfirmed != nil)) {
|
|
|
|
if email != "" {
|
|
|
|
email = *m.Email
|
|
|
|
}
|
|
|
|
if (m.Primary != nil && *m.Primary) || (m.IsPrimary != nil && *m.IsPrimary) {
|
|
|
|
return *m.Email, nil
|
|
|
|
}
|
2017-04-06 15:19:07 +00:00
|
|
|
}
|
|
|
|
}
|
2020-12-17 06:40:24 +00:00
|
|
|
if email == "" {
|
|
|
|
return "", errors.New("No primary email address")
|
|
|
|
}
|
|
|
|
return email, nil
|
2017-04-06 15:19:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// ofDomain makes sure that the email is in one of the required domains
|
|
|
|
func ofDomain(requiredDomains []string, email string) bool {
|
|
|
|
for _, domain := range requiredDomains {
|
|
|
|
emailDomain := fmt.Sprintf("@%s", domain)
|
|
|
|
if strings.HasSuffix(email, emailDomain) {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
}
|
2017-12-10 17:33:50 +00:00
|
|
|
|
2018-02-20 08:47:42 +00:00
|
|
|
// PrincipalIDFromClaims verifies an optional id_token and extracts email address of the user
|
2017-12-10 17:33:50 +00:00
|
|
|
func (g *Generic) PrincipalIDFromClaims(claims gojwt.MapClaims) (string, error) {
|
2017-12-13 08:13:11 +00:00
|
|
|
if id, ok := claims[g.APIKey].(string); ok {
|
|
|
|
return id, nil
|
|
|
|
}
|
|
|
|
return "", fmt.Errorf("no claim for %s", g.APIKey)
|
2017-12-10 17:33:50 +00:00
|
|
|
}
|
2018-02-20 08:47:42 +00:00
|
|
|
|
|
|
|
// GroupFromClaims verifies an optional id_token, extracts the email address of the user and splits off the domain part
|
|
|
|
func (g *Generic) GroupFromClaims(claims gojwt.MapClaims) (string, error) {
|
|
|
|
if id, ok := claims[g.APIKey].(string); ok {
|
|
|
|
email := strings.Split(id, "@")
|
|
|
|
if len(email) != 2 {
|
|
|
|
g.Logger.Error("malformed email address, expected %q to contain @ symbol", id)
|
|
|
|
return "DEFAULT", nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return email[1], nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return "", fmt.Errorf("no claim for %s", g.APIKey)
|
|
|
|
}
|