github.com/minio/console@v1.4.1/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/v3/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 client *http.Client 116 } 117 118 // DefaultDerivedKey is the key used to compute the HMAC for signing the oauth state parameter 119 // its derived using pbkdf on CONSOLE_IDP_HMAC_PASSPHRASE with CONSOLE_IDP_HMAC_SALT 120 var DefaultDerivedKey = func() []byte { 121 return pbkdf2.Key([]byte(getPassphraseForIDPHmac()), []byte(getSaltForIDPHmac()), 4096, 32, sha1.New) 122 } 123 124 const ( 125 schemeHTTP = "http" 126 schemeHTTPS = "https" 127 ) 128 129 func getLoginCallbackURL(r *http.Request) string { 130 scheme := getSourceScheme(r) 131 if scheme == "" { 132 if r.TLS != nil { 133 scheme = schemeHTTPS 134 } else { 135 scheme = schemeHTTP 136 } 137 } 138 139 redirectURL := scheme + "://" + r.Host + "/oauth_callback" 140 _, err := url.Parse(redirectURL) 141 if err != nil { 142 panic(err) 143 } 144 return redirectURL 145 } 146 147 var requiredResponseTypes = set.CreateStringSet("code") 148 149 // NewOauth2ProviderClient instantiates a new oauth2 client using the configured credentials 150 // it returns a *Provider object that contains the necessary configuration to initiate an 151 // oauth2 authentication flow. 152 // 153 // We only support Authentication with the Authorization Code Flow - spec: 154 // https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth 155 func NewOauth2ProviderClient(scopes []string, r *http.Request, httpClient *http.Client) (*Provider, error) { 156 ddoc, err := parseDiscoveryDoc(r.Context(), GetIDPURL(), httpClient) 157 if err != nil { 158 return nil, err 159 } 160 161 supportedResponseTypes := set.NewStringSet() 162 for _, responseType := range ddoc.ResponseTypesSupported { 163 // FIXME: ResponseTypesSupported is a JSON array of strings - it 164 // may not actually have strings with spaces inside them - 165 // making the following code unnecessary. 166 for _, s := range strings.Fields(responseType) { 167 supportedResponseTypes.Add(s) 168 } 169 } 170 isSupported := requiredResponseTypes.Difference(supportedResponseTypes).IsEmpty() 171 172 if !isSupported { 173 return nil, fmt.Errorf("expected 'code' response type - got %s, login not allowed", ddoc.ResponseTypesSupported) 174 } 175 176 // If provided scopes are empty we use a default list or the user configured list 177 if len(scopes) == 0 { 178 scopes = strings.Split(getIDPScopes(), ",") 179 } 180 181 redirectURL := GetIDPCallbackURL() 182 183 if GetIDPCallbackURLDynamic() { 184 // dynamic redirect if set, will generate redirect URLs 185 // dynamically based on incoming requests. 186 redirectURL = getLoginCallbackURL(r) 187 } 188 189 // add "openid" scope always. 190 scopes = append(scopes, "openid") 191 192 client := new(Provider) 193 client.oauth2Config = &xoauth2.Config{ 194 ClientID: GetIDPClientID(), 195 ClientSecret: GetIDPSecret(), 196 RedirectURL: redirectURL, 197 Endpoint: oauth2.Endpoint{ 198 AuthURL: ddoc.AuthEndpoint, 199 TokenURL: ddoc.TokenEndpoint, 200 }, 201 Scopes: scopes, 202 } 203 204 client.IDPName = GetIDPClientID() 205 client.UserInfo = GetIDPUserInfo() 206 client.client = httpClient 207 208 return client, nil 209 } 210 211 var defaultScopes = []string{"openid", "profile", "email"} 212 213 // NewOauth2ProviderClientByName returns a provider if present specified by the input name of the provider. 214 func (ois OpenIDPCfg) NewOauth2ProviderClientByName(name string, scopes []string, r *http.Request, clnt *http.Client) (provider *Provider, err error) { 215 oi, ok := ois[name] 216 if !ok { 217 return nil, fmt.Errorf("%s IDP provider does not exist", name) 218 } 219 return oi.GetOauth2Provider(name, scopes, r, clnt) 220 } 221 222 // NewOauth2ProviderClient instantiates a new oauth2 client using the 223 // `OpenIDPCfg` configuration struct. It returns a *Provider object that 224 // contains the necessary configuration to initiate an oauth2 authentication 225 // flow. 226 // 227 // We only support Authentication with the Authorization Code Flow - spec: 228 // https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth 229 func (ois OpenIDPCfg) NewOauth2ProviderClient(scopes []string, r *http.Request, clnt *http.Client) (provider *Provider, providerCfg ProviderConfig, err error) { 230 for name, oi := range ois { 231 provider, err = oi.GetOauth2Provider(name, scopes, r, clnt) 232 if err != nil { 233 // Upon error look for the next IDP. 234 continue 235 } 236 // Upon success return right away. 237 providerCfg = oi 238 break 239 } 240 return provider, providerCfg, err 241 } 242 243 type User struct { 244 AppMetadata map[string]interface{} `json:"app_metadata"` 245 Blocked bool `json:"blocked"` 246 CreatedAt string `json:"created_at"` 247 Email string `json:"email"` 248 EmailVerified bool `json:"email_verified"` 249 FamilyName string `json:"family_name"` 250 GivenName string `json:"given_name"` 251 Identities []interface{} `json:"identities"` 252 LastIP string `json:"last_ip"` 253 LastLogin string `json:"last_login"` 254 LastPasswordReset string `json:"last_password_reset"` 255 LoginsCount int `json:"logins_count"` 256 MultiFactor string `json:"multifactor"` 257 Name string `json:"name"` 258 Nickname string `json:"nickname"` 259 PhoneNumber string `json:"phone_number"` 260 PhoneVerified bool `json:"phone_verified"` 261 Picture string `json:"picture"` 262 UpdatedAt string `json:"updated_at"` 263 UserID string `json:"user_id"` 264 UserMetadata map[string]interface{} `json:"user_metadata"` 265 Username string `json:"username"` 266 } 267 268 // StateKeyFunc - is a function that returns a key used in OAuth Authorization 269 // flow state generation and verification. 270 type StateKeyFunc func() []byte 271 272 // VerifyIdentity will contact the configured IDP to the user identity based on the authorization code and state 273 // if the user is valid, then it will contact MinIO to get valid sts credentials based on the identity provided by the IDP 274 func (client *Provider) VerifyIdentity(ctx context.Context, code, state, roleARN string, keyFunc StateKeyFunc) (*credentials.Credentials, error) { 275 // verify the provided state is valid (prevents CSRF attacks) 276 if err := validateOauth2State(state, keyFunc); err != nil { 277 return nil, err 278 } 279 getWebTokenExpiry := func() (*credentials.WebIdentityToken, error) { 280 customCtx := context.WithValue(ctx, oauth2.HTTPClient, client.client) 281 oauth2Token, err := client.oauth2Config.Exchange(customCtx, code) 282 if err != nil { 283 return nil, err 284 } 285 if !oauth2Token.Valid() { 286 return nil, errors.New("invalid token") 287 } 288 client.RefreshToken = oauth2Token.RefreshToken 289 290 envStsDuration := env.Get(token.ConsoleSTSDuration, "") 291 stsDuration, err := time.ParseDuration(envStsDuration) 292 293 expiration := 12 * time.Hour 294 295 if err == nil && stsDuration > 0 { 296 expiration = stsDuration 297 } else { 298 // Use the expiration configured in the token itself if it is closer than the configured value 299 if exp := oauth2Token.Expiry.Sub(time.Now().UTC()); exp < expiration { 300 expiration = exp 301 } 302 } 303 304 // Minimum duration in S3 spec is 15 minutes, do not bother returning 305 // an error to the user and force the minimum duration instead 306 if expiration < 900*time.Second { 307 expiration = 900 * time.Second 308 } 309 310 idToken := oauth2Token.Extra("id_token") 311 if idToken == nil { 312 return nil, errors.New("missing id_token") 313 } 314 token := &credentials.WebIdentityToken{ 315 Token: idToken.(string), 316 Expiry: int(expiration.Seconds()), 317 } 318 if client.UserInfo { // look for access_token only if userinfo is requested. 319 accessToken := oauth2Token.Extra("access_token") 320 if accessToken == nil { 321 return nil, errors.New("missing access_token") 322 } 323 token.AccessToken = accessToken.(string) 324 } 325 return token, nil 326 } 327 stsEndpoint := GetSTSEndpoint() 328 329 sts := credentials.New(&credentials.STSWebIdentity{ 330 Client: client.client, 331 STSEndpoint: stsEndpoint, 332 GetWebIDTokenExpiry: getWebTokenExpiry, 333 RoleARN: roleARN, 334 }) 335 return sts, nil 336 } 337 338 // VerifyIdentityForOperator will contact the configured IDP and validate the user identity based on the authorization code and state 339 func (client *Provider) VerifyIdentityForOperator(ctx context.Context, code, state string, keyFunc StateKeyFunc) (*xoauth2.Token, error) { 340 // verify the provided state is valid (prevents CSRF attacks) 341 if err := validateOauth2State(state, keyFunc); err != nil { 342 return nil, err 343 } 344 customCtx := context.WithValue(ctx, oauth2.HTTPClient, client.client) 345 oauth2Token, err := client.oauth2Config.Exchange(customCtx, code) 346 if err != nil { 347 return nil, err 348 } 349 if !oauth2Token.Valid() { 350 return nil, errors.New("invalid token") 351 } 352 return oauth2Token, nil 353 } 354 355 // validateOauth2State validates the provided state was originated using the same 356 // instance (or one configured using the same secrets) of Console, this is basically used to prevent CSRF attacks 357 // https://security.stackexchange.com/questions/20187/oauth2-cross-site-request-forgery-and-state-parameter 358 func validateOauth2State(state string, keyFunc StateKeyFunc) error { 359 // state contains a base64 encoded string that may ends with "==", the browser encodes that to "%3D%3D" 360 // query unescape is need it before trying to decode the base64 string 361 encodedMessage, err := url.QueryUnescape(state) 362 if err != nil { 363 return err 364 } 365 // decode the state parameter value 366 message, err := base64.StdEncoding.DecodeString(encodedMessage) 367 if err != nil { 368 return err 369 } 370 s := strings.Split(string(message), ":") 371 // Validate that the decoded message has the right format "message:hmac" 372 if len(s) != 2 { 373 return fmt.Errorf("invalid number of tokens, expected only 2, got %d instead", len(s)) 374 } 375 // extract the state and hmac 376 incomingState, incomingHmac := s[0], s[1] 377 // validate that hmac(incomingState + pbkdf2(secret, salt)) == incomingHmac 378 if calculatedHmac := utils.ComputeHmac256(incomingState, keyFunc()); calculatedHmac != incomingHmac { 379 return fmt.Errorf("oauth2 state is invalid, expected %s, got %s", calculatedHmac, incomingHmac) 380 } 381 return nil 382 } 383 384 // parseDiscoveryDoc parses a discovery doc from an OAuth provider 385 // into a DiscoveryDoc struct that have the correct endpoints 386 func parseDiscoveryDoc(ctx context.Context, ustr string, httpClient *http.Client) (DiscoveryDoc, error) { 387 d := DiscoveryDoc{} 388 req, err := http.NewRequestWithContext(ctx, http.MethodGet, ustr, nil) 389 if err != nil { 390 return d, err 391 } 392 clnt := http.Client{ 393 Transport: httpClient.Transport, 394 } 395 resp, err := clnt.Do(req) 396 if err != nil { 397 return d, err 398 } 399 defer resp.Body.Close() 400 if resp.StatusCode != http.StatusOK { 401 return d, err 402 } 403 dec := json.NewDecoder(resp.Body) 404 if err = dec.Decode(&d); err != nil { 405 return d, err 406 } 407 return d, nil 408 } 409 410 // GetRandomStateWithHMAC computes message + hmac(message, pbkdf2(key, salt)) to be used as state during the oauth authorization 411 func GetRandomStateWithHMAC(length int, keyFunc StateKeyFunc) string { 412 state := utils.RandomCharString(length) 413 hmac := utils.ComputeHmac256(state, keyFunc()) 414 return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", state, hmac))) 415 } 416 417 type LoginURLParams struct { 418 State string `json:"state"` 419 IDPName string `json:"idp_name"` 420 } 421 422 // GenerateLoginURL returns a new login URL based on the configured IDP 423 func (client *Provider) GenerateLoginURL(keyFunc StateKeyFunc, iDPName string) string { 424 // generates random state and sign it using HMAC256 425 state := GetRandomStateWithHMAC(25, keyFunc) 426 427 configureID := "_" 428 429 if iDPName != "" { 430 configureID = iDPName 431 } 432 433 lgParams := LoginURLParams{ 434 State: state, 435 IDPName: configureID, 436 } 437 438 jsonEnc, err := json.Marshal(lgParams) 439 if err != nil { 440 return "" 441 } 442 443 stEncode := base64.StdEncoding.EncodeToString(jsonEnc) 444 loginURL := client.oauth2Config.AuthCodeURL(stEncode) 445 446 return strings.TrimSpace(loginURL) 447 }