github.com/minio/console@v1.3.0/pkg/auth/idp/oauth2/provider.go (about)

     1  // This file is part of MinIO Console Server
     2  // Copyright (c) 2021 MinIO, Inc.
     3  //
     4  // This program is free software: you can redistribute it and/or modify
     5  // it under the terms of the GNU Affero General Public License as published by
     6  // the Free Software Foundation, either version 3 of the License, or
     7  // (at your option) any later version.
     8  //
     9  // This program is distributed in the hope that it will be useful,
    10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
    11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    12  // GNU Affero General Public License for more details.
    13  //
    14  // You should have received a copy of the GNU Affero General Public License
    15  // along with this program.  If not, see <http://www.gnu.org/licenses/>.
    16  
    17  package oauth2
    18  
    19  import (
    20  	"context"
    21  	"crypto/sha1"
    22  	"encoding/base64"
    23  	"encoding/json"
    24  	"errors"
    25  	"fmt"
    26  	"net/http"
    27  	"net/url"
    28  	"strings"
    29  	"time"
    30  
    31  	"github.com/minio/console/pkg/auth/token"
    32  	"github.com/minio/console/pkg/auth/utils"
    33  	"github.com/minio/minio-go/v7/pkg/credentials"
    34  	"github.com/minio/minio-go/v7/pkg/set"
    35  	"github.com/minio/pkg/v2/env"
    36  	"golang.org/x/crypto/pbkdf2"
    37  	"golang.org/x/oauth2"
    38  	xoauth2 "golang.org/x/oauth2"
    39  )
    40  
    41  type Configuration interface {
    42  	Exchange(ctx context.Context, code string, opts ...xoauth2.AuthCodeOption) (*xoauth2.Token, error)
    43  	AuthCodeURL(state string, opts ...xoauth2.AuthCodeOption) string
    44  	PasswordCredentialsToken(ctx context.Context, username, password string) (*xoauth2.Token, error)
    45  	Client(ctx context.Context, t *xoauth2.Token) *http.Client
    46  	TokenSource(ctx context.Context, t *xoauth2.Token) xoauth2.TokenSource
    47  }
    48  
    49  type Config struct {
    50  	xoauth2.Config
    51  }
    52  
    53  // DiscoveryDoc - parses the output from openid-configuration
    54  // for example https://accounts.google.com/.well-known/openid-configuration
    55  type DiscoveryDoc struct {
    56  	Issuer                           string   `json:"issuer,omitempty"`
    57  	AuthEndpoint                     string   `json:"authorization_endpoint,omitempty"`
    58  	TokenEndpoint                    string   `json:"token_endpoint,omitempty"`
    59  	UserInfoEndpoint                 string   `json:"userinfo_endpoint,omitempty"`
    60  	RevocationEndpoint               string   `json:"revocation_endpoint,omitempty"`
    61  	JwksURI                          string   `json:"jwks_uri,omitempty"`
    62  	ResponseTypesSupported           []string `json:"response_types_supported,omitempty"`
    63  	SubjectTypesSupported            []string `json:"subject_types_supported,omitempty"`
    64  	IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported,omitempty"`
    65  	ScopesSupported                  []string `json:"scopes_supported,omitempty"`
    66  	TokenEndpointAuthMethods         []string `json:"token_endpoint_auth_methods_supported,omitempty"`
    67  	ClaimsSupported                  []string `json:"claims_supported,omitempty"`
    68  	CodeChallengeMethodsSupported    []string `json:"code_challenge_methods_supported,omitempty"`
    69  }
    70  
    71  func (ac Config) Exchange(ctx context.Context, code string, opts ...xoauth2.AuthCodeOption) (*xoauth2.Token, error) {
    72  	return ac.Config.Exchange(ctx, code, opts...)
    73  }
    74  
    75  func (ac Config) AuthCodeURL(state string, opts ...xoauth2.AuthCodeOption) string {
    76  	return ac.Config.AuthCodeURL(state, opts...)
    77  }
    78  
    79  func (ac Config) PasswordCredentialsToken(ctx context.Context, username, password string) (*xoauth2.Token, error) {
    80  	return ac.Config.PasswordCredentialsToken(ctx, username, password)
    81  }
    82  
    83  func (ac Config) Client(ctx context.Context, t *xoauth2.Token) *http.Client {
    84  	return ac.Config.Client(ctx, t)
    85  }
    86  
    87  func (ac Config) TokenSource(ctx context.Context, t *xoauth2.Token) xoauth2.TokenSource {
    88  	return ac.Config.TokenSource(ctx, t)
    89  }
    90  
    91  // Provider is a wrapper of the oauth2 configuration and the oidc provider
    92  type Provider struct {
    93  	// oauth2Config is an interface configuration that contains the following fields
    94  	// Config{
    95  	// 	 IDPName string
    96  	//	 ClientSecret string
    97  	//	 RedirectURL string
    98  	//	 Endpoint oauth2.Endpoint
    99  	//	 Scopes []string
   100  	// }
   101  	// - IDPName is the public identifier for this application
   102  	// - ClientSecret is a shared secret between this application and the authorization server
   103  	// - RedirectURL is the URL to redirect users going through
   104  	//   the OAuth flow, after the resource owner's URLs.
   105  	// - Endpoint contains the resource server's token endpoint
   106  	//   URLs. These are constants specific to each server and are
   107  	//   often available via site-specific packages, such as
   108  	//   google.Endpoint or github.Endpoint.
   109  	// - Scopes specifies optional requested permissions.
   110  	IDPName string
   111  	// if enabled means that we need extrace access_token as well
   112  	UserInfo       bool
   113  	RefreshToken   string
   114  	oauth2Config   Configuration
   115  	provHTTPClient *http.Client
   116  	stsHTTPClient  *http.Client
   117  }
   118  
   119  // DefaultDerivedKey is the key used to compute the HMAC for signing the oauth state parameter
   120  // its derived using pbkdf on CONSOLE_IDP_HMAC_PASSPHRASE with CONSOLE_IDP_HMAC_SALT
   121  var DefaultDerivedKey = func() []byte {
   122  	return pbkdf2.Key([]byte(getPassphraseForIDPHmac()), []byte(getSaltForIDPHmac()), 4096, 32, sha1.New)
   123  }
   124  
   125  const (
   126  	schemeHTTP  = "http"
   127  	schemeHTTPS = "https"
   128  )
   129  
   130  func getLoginCallbackURL(r *http.Request) string {
   131  	scheme := getSourceScheme(r)
   132  	if scheme == "" {
   133  		if r.TLS != nil {
   134  			scheme = schemeHTTPS
   135  		} else {
   136  			scheme = schemeHTTP
   137  		}
   138  	}
   139  
   140  	redirectURL := scheme + "://" + r.Host + "/oauth_callback"
   141  	_, err := url.Parse(redirectURL)
   142  	if err != nil {
   143  		panic(err)
   144  	}
   145  	return redirectURL
   146  }
   147  
   148  var requiredResponseTypes = set.CreateStringSet("code")
   149  
   150  // NewOauth2ProviderClient instantiates a new oauth2 client using the configured credentials
   151  // it returns a *Provider object that contains the necessary configuration to initiate an
   152  // oauth2 authentication flow.
   153  //
   154  // We only support Authentication with the Authorization Code Flow - spec:
   155  // https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth
   156  func NewOauth2ProviderClient(scopes []string, r *http.Request, httpClient *http.Client) (*Provider, error) {
   157  	ddoc, err := parseDiscoveryDoc(r.Context(), GetIDPURL(), httpClient)
   158  	if err != nil {
   159  		return nil, err
   160  	}
   161  
   162  	supportedResponseTypes := set.NewStringSet()
   163  	for _, responseType := range ddoc.ResponseTypesSupported {
   164  		// FIXME: ResponseTypesSupported is a JSON array of strings - it
   165  		// may not actually have strings with spaces inside them -
   166  		// making the following code unnecessary.
   167  		for _, s := range strings.Fields(responseType) {
   168  			supportedResponseTypes.Add(s)
   169  		}
   170  	}
   171  	isSupported := requiredResponseTypes.Difference(supportedResponseTypes).IsEmpty()
   172  
   173  	if !isSupported {
   174  		return nil, fmt.Errorf("expected 'code' response type - got %s, login not allowed", ddoc.ResponseTypesSupported)
   175  	}
   176  
   177  	// If provided scopes are empty we use a default list or the user configured list
   178  	if len(scopes) == 0 {
   179  		scopes = strings.Split(getIDPScopes(), ",")
   180  	}
   181  
   182  	redirectURL := GetIDPCallbackURL()
   183  
   184  	if GetIDPCallbackURLDynamic() {
   185  		// dynamic redirect if set, will generate redirect URLs
   186  		// dynamically based on incoming requests.
   187  		redirectURL = getLoginCallbackURL(r)
   188  	}
   189  
   190  	// add "openid" scope always.
   191  	scopes = append(scopes, "openid")
   192  
   193  	client := new(Provider)
   194  	client.oauth2Config = &xoauth2.Config{
   195  		ClientID:     GetIDPClientID(),
   196  		ClientSecret: GetIDPSecret(),
   197  		RedirectURL:  redirectURL,
   198  		Endpoint: oauth2.Endpoint{
   199  			AuthURL:  ddoc.AuthEndpoint,
   200  			TokenURL: ddoc.TokenEndpoint,
   201  		},
   202  		Scopes: scopes,
   203  	}
   204  
   205  	client.IDPName = GetIDPClientID()
   206  	client.UserInfo = GetIDPUserInfo()
   207  	client.provHTTPClient = httpClient
   208  
   209  	return client, nil
   210  }
   211  
   212  var defaultScopes = []string{"openid", "profile", "email"}
   213  
   214  // NewOauth2ProviderClientByName returns a provider if present specified by the input name of the provider.
   215  func (ois OpenIDPCfg) NewOauth2ProviderClientByName(name string, scopes []string, r *http.Request, idpClient, stsClient *http.Client) (provider *Provider, err error) {
   216  	oi, ok := ois[name]
   217  	if !ok {
   218  		return nil, fmt.Errorf("%s IDP provider does not exist", name)
   219  	}
   220  	return oi.GetOauth2Provider(name, scopes, r, idpClient, stsClient)
   221  }
   222  
   223  // NewOauth2ProviderClient instantiates a new oauth2 client using the
   224  // `OpenIDPCfg` configuration struct. It returns a *Provider object that
   225  // contains the necessary configuration to initiate an oauth2 authentication
   226  // flow.
   227  //
   228  // We only support Authentication with the Authorization Code Flow - spec:
   229  // https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth
   230  func (ois OpenIDPCfg) NewOauth2ProviderClient(scopes []string, r *http.Request, idpClient, stsClient *http.Client) (provider *Provider, providerCfg ProviderConfig, err error) {
   231  	for name, oi := range ois {
   232  		provider, err = oi.GetOauth2Provider(name, scopes, r, idpClient, stsClient)
   233  		if err != nil {
   234  			// Upon error look for the next IDP.
   235  			continue
   236  		}
   237  		// Upon success return right away.
   238  		providerCfg = oi
   239  		break
   240  	}
   241  	return provider, providerCfg, err
   242  }
   243  
   244  type User struct {
   245  	AppMetadata       map[string]interface{} `json:"app_metadata"`
   246  	Blocked           bool                   `json:"blocked"`
   247  	CreatedAt         string                 `json:"created_at"`
   248  	Email             string                 `json:"email"`
   249  	EmailVerified     bool                   `json:"email_verified"`
   250  	FamilyName        string                 `json:"family_name"`
   251  	GivenName         string                 `json:"given_name"`
   252  	Identities        []interface{}          `json:"identities"`
   253  	LastIP            string                 `json:"last_ip"`
   254  	LastLogin         string                 `json:"last_login"`
   255  	LastPasswordReset string                 `json:"last_password_reset"`
   256  	LoginsCount       int                    `json:"logins_count"`
   257  	MultiFactor       string                 `json:"multifactor"`
   258  	Name              string                 `json:"name"`
   259  	Nickname          string                 `json:"nickname"`
   260  	PhoneNumber       string                 `json:"phone_number"`
   261  	PhoneVerified     bool                   `json:"phone_verified"`
   262  	Picture           string                 `json:"picture"`
   263  	UpdatedAt         string                 `json:"updated_at"`
   264  	UserID            string                 `json:"user_id"`
   265  	UserMetadata      map[string]interface{} `json:"user_metadata"`
   266  	Username          string                 `json:"username"`
   267  }
   268  
   269  // StateKeyFunc - is a function that returns a key used in OAuth Authorization
   270  // flow state generation and verification.
   271  type StateKeyFunc func() []byte
   272  
   273  // VerifyIdentity will contact the configured IDP to the user identity based on the authorization code and state
   274  // if the user is valid, then it will contact MinIO to get valid sts credentials based on the identity provided by the IDP
   275  func (client *Provider) VerifyIdentity(ctx context.Context, code, state, roleARN string, keyFunc StateKeyFunc) (*credentials.Credentials, error) {
   276  	// verify the provided state is valid (prevents CSRF attacks)
   277  	if err := validateOauth2State(state, keyFunc); err != nil {
   278  		return nil, err
   279  	}
   280  	getWebTokenExpiry := func() (*credentials.WebIdentityToken, error) {
   281  		customCtx := context.WithValue(ctx, oauth2.HTTPClient, client.provHTTPClient)
   282  		oauth2Token, err := client.oauth2Config.Exchange(customCtx, code)
   283  		if err != nil {
   284  			return nil, err
   285  		}
   286  		if !oauth2Token.Valid() {
   287  			return nil, errors.New("invalid token")
   288  		}
   289  		client.RefreshToken = oauth2Token.RefreshToken
   290  
   291  		envStsDuration := env.Get(token.ConsoleSTSDuration, "")
   292  		stsDuration, err := time.ParseDuration(envStsDuration)
   293  
   294  		expiration := 12 * time.Hour
   295  
   296  		if err == nil && stsDuration > 0 {
   297  			expiration = stsDuration
   298  		} else {
   299  			// Use the expiration configured in the token itself if it is closer than the configured value
   300  			if exp := oauth2Token.Expiry.Sub(time.Now().UTC()); exp < expiration {
   301  				expiration = exp
   302  			}
   303  		}
   304  
   305  		// Minimum duration in S3 spec is 15 minutes, do not bother returning
   306  		// an error to the user and force the minimum duration instead
   307  		if expiration < 900*time.Second {
   308  			expiration = 900 * time.Second
   309  		}
   310  
   311  		idToken := oauth2Token.Extra("id_token")
   312  		if idToken == nil {
   313  			return nil, errors.New("missing id_token")
   314  		}
   315  		token := &credentials.WebIdentityToken{
   316  			Token:  idToken.(string),
   317  			Expiry: int(expiration.Seconds()),
   318  		}
   319  		if client.UserInfo { // look for access_token only if userinfo is requested.
   320  			accessToken := oauth2Token.Extra("access_token")
   321  			if accessToken == nil {
   322  				return nil, errors.New("missing access_token")
   323  			}
   324  			token.AccessToken = accessToken.(string)
   325  		}
   326  		return token, nil
   327  	}
   328  	stsEndpoint := GetSTSEndpoint()
   329  
   330  	sts := credentials.New(&credentials.STSWebIdentity{
   331  		Client:              client.stsHTTPClient,
   332  		STSEndpoint:         stsEndpoint,
   333  		GetWebIDTokenExpiry: getWebTokenExpiry,
   334  		RoleARN:             roleARN,
   335  	})
   336  	return sts, nil
   337  }
   338  
   339  // VerifyIdentityForOperator will contact the configured IDP and validate the user identity based on the authorization code and state
   340  func (client *Provider) VerifyIdentityForOperator(ctx context.Context, code, state string, keyFunc StateKeyFunc) (*xoauth2.Token, error) {
   341  	// verify the provided state is valid (prevents CSRF attacks)
   342  	if err := validateOauth2State(state, keyFunc); err != nil {
   343  		return nil, err
   344  	}
   345  	customCtx := context.WithValue(ctx, oauth2.HTTPClient, client.provHTTPClient)
   346  	oauth2Token, err := client.oauth2Config.Exchange(customCtx, code)
   347  	if err != nil {
   348  		return nil, err
   349  	}
   350  	if !oauth2Token.Valid() {
   351  		return nil, errors.New("invalid token")
   352  	}
   353  	return oauth2Token, nil
   354  }
   355  
   356  // validateOauth2State validates the provided state was originated using the same
   357  // instance (or one configured using the same secrets) of Console, this is basically used to prevent CSRF attacks
   358  // https://security.stackexchange.com/questions/20187/oauth2-cross-site-request-forgery-and-state-parameter
   359  func validateOauth2State(state string, keyFunc StateKeyFunc) error {
   360  	// state contains a base64 encoded string that may ends with "==", the browser encodes that to "%3D%3D"
   361  	// query unescape is need it before trying to decode the base64 string
   362  	encodedMessage, err := url.QueryUnescape(state)
   363  	if err != nil {
   364  		return err
   365  	}
   366  	// decode the state parameter value
   367  	message, err := base64.StdEncoding.DecodeString(encodedMessage)
   368  	if err != nil {
   369  		return err
   370  	}
   371  	s := strings.Split(string(message), ":")
   372  	// Validate that the decoded message has the right format "message:hmac"
   373  	if len(s) != 2 {
   374  		return fmt.Errorf("invalid number of tokens, expected only 2, got %d instead", len(s))
   375  	}
   376  	// extract the state and hmac
   377  	incomingState, incomingHmac := s[0], s[1]
   378  	// validate that hmac(incomingState + pbkdf2(secret, salt)) == incomingHmac
   379  	if calculatedHmac := utils.ComputeHmac256(incomingState, keyFunc()); calculatedHmac != incomingHmac {
   380  		return fmt.Errorf("oauth2 state is invalid, expected %s, got %s", calculatedHmac, incomingHmac)
   381  	}
   382  	return nil
   383  }
   384  
   385  // parseDiscoveryDoc parses a discovery doc from an OAuth provider
   386  // into a DiscoveryDoc struct that have the correct endpoints
   387  func parseDiscoveryDoc(ctx context.Context, ustr string, httpClient *http.Client) (DiscoveryDoc, error) {
   388  	d := DiscoveryDoc{}
   389  	req, err := http.NewRequestWithContext(ctx, http.MethodGet, ustr, nil)
   390  	if err != nil {
   391  		return d, err
   392  	}
   393  	clnt := http.Client{
   394  		Transport: httpClient.Transport,
   395  	}
   396  	resp, err := clnt.Do(req)
   397  	if err != nil {
   398  		return d, err
   399  	}
   400  	defer resp.Body.Close()
   401  	if resp.StatusCode != http.StatusOK {
   402  		return d, err
   403  	}
   404  	dec := json.NewDecoder(resp.Body)
   405  	if err = dec.Decode(&d); err != nil {
   406  		return d, err
   407  	}
   408  	return d, nil
   409  }
   410  
   411  // GetRandomStateWithHMAC computes message + hmac(message, pbkdf2(key, salt)) to be used as state during the oauth authorization
   412  func GetRandomStateWithHMAC(length int, keyFunc StateKeyFunc) string {
   413  	state := utils.RandomCharString(length)
   414  	hmac := utils.ComputeHmac256(state, keyFunc())
   415  	return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", state, hmac)))
   416  }
   417  
   418  type LoginURLParams struct {
   419  	State   string `json:"state"`
   420  	IDPName string `json:"idp_name"`
   421  }
   422  
   423  // GenerateLoginURL returns a new login URL based on the configured IDP
   424  func (client *Provider) GenerateLoginURL(keyFunc StateKeyFunc, iDPName string) string {
   425  	// generates random state and sign it using HMAC256
   426  	state := GetRandomStateWithHMAC(25, keyFunc)
   427  
   428  	configureID := "_"
   429  
   430  	if iDPName != "" {
   431  		configureID = iDPName
   432  	}
   433  
   434  	lgParams := LoginURLParams{
   435  		State:   state,
   436  		IDPName: configureID,
   437  	}
   438  
   439  	jsonEnc, err := json.Marshal(lgParams)
   440  	if err != nil {
   441  		return ""
   442  	}
   443  
   444  	stEncode := base64.StdEncoding.EncodeToString(jsonEnc)
   445  	loginURL := client.oauth2Config.AuthCodeURL(stEncode)
   446  
   447  	return strings.TrimSpace(loginURL)
   448  }