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 }