This PR adds support for oidc in mcs, to enable idp authentication you need to pass the following environment variables and restart mcs. ``` MCS_IDP_URL="" MCS_IDP_CLIENT_ID="" MCS_IDP_SECRET="" MCS_IDP_CALLBACK="" ```
230 lines
8.7 KiB
Go
230 lines
8.7 KiB
Go
// This file is part of MinIO Console Server
|
|
// Copyright (c) 2020 MinIO, Inc.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
package oauth2
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/coreos/go-oidc"
|
|
"github.com/minio/mcs/pkg/auth/utils"
|
|
"golang.org/x/crypto/pbkdf2"
|
|
xoauth2 "golang.org/x/oauth2"
|
|
)
|
|
|
|
var (
|
|
errGeneric = errors.New("an error occurred, please try again")
|
|
)
|
|
|
|
type Configuration interface {
|
|
Exchange(ctx context.Context, code string, opts ...xoauth2.AuthCodeOption) (*xoauth2.Token, error)
|
|
AuthCodeURL(state string, opts ...xoauth2.AuthCodeOption) string
|
|
PasswordCredentialsToken(ctx context.Context, username string, password string) (*xoauth2.Token, error)
|
|
Client(ctx context.Context, t *xoauth2.Token) *http.Client
|
|
TokenSource(ctx context.Context, t *xoauth2.Token) xoauth2.TokenSource
|
|
}
|
|
|
|
type Config struct {
|
|
xoauth2.Config
|
|
}
|
|
|
|
func (ac Config) Exchange(ctx context.Context, code string, opts ...xoauth2.AuthCodeOption) (*xoauth2.Token, error) {
|
|
return ac.Exchange(ctx, code, opts...)
|
|
}
|
|
|
|
func (ac Config) AuthCodeURL(state string, opts ...xoauth2.AuthCodeOption) string {
|
|
return ac.AuthCodeURL(state, opts...)
|
|
}
|
|
|
|
func (ac Config) PasswordCredentialsToken(ctx context.Context, username string, password string) (*xoauth2.Token, error) {
|
|
return ac.PasswordCredentialsToken(ctx, username, password)
|
|
}
|
|
|
|
func (ac Config) Client(ctx context.Context, t *xoauth2.Token) *http.Client {
|
|
return ac.Client(ctx, t)
|
|
}
|
|
|
|
func (ac Config) TokenSource(ctx context.Context, t *xoauth2.Token) xoauth2.TokenSource {
|
|
return ac.TokenSource(ctx, t)
|
|
}
|
|
|
|
// Provider is a wrapper of the oauth2 configuration and the oidc provider
|
|
type Provider struct {
|
|
// oauth2Config is an interface configuration that contains the following fields
|
|
// Config{
|
|
// ClientID string
|
|
// ClientSecret string
|
|
// RedirectURL string
|
|
// Endpoint oauth2.Endpoint
|
|
// Scopes []string
|
|
// }
|
|
// - ClientID is the public identifier for this application
|
|
// - ClientSecret is a shared secret between this application and the authorization server
|
|
// - RedirectURL is the URL to redirect users going through
|
|
// the OAuth flow, after the resource owner's URLs.
|
|
// - Endpoint contains the resource server's token endpoint
|
|
// URLs. These are constants specific to each server and are
|
|
// often available via site-specific packages, such as
|
|
// google.Endpoint or github.Endpoint.
|
|
// - Scopes specifies optional requested permissions.
|
|
ClientID string
|
|
oauth2Config Configuration
|
|
oidcProvider *oidc.Provider
|
|
}
|
|
|
|
// derivedKey is the key used to compute the HMAC for signing the oauth state parameter
|
|
// its derived using pbkdf on MCS_IDP_HMAC_PASSPHRASE with MCS_IDP_HMAC_SALT
|
|
var derivedKey = pbkdf2.Key([]byte(getPassphraseForIdpHmac()), []byte(getSaltForIdpHmac()), 4096, 32, sha1.New)
|
|
|
|
// NewOauth2ProviderClient instantiates a new oauth2 client using the configured credentials
|
|
// it returns a *Provider object that contains the necessary configuration to initiate an
|
|
// oauth2 authentication flow
|
|
func NewOauth2ProviderClient(ctx context.Context, scopes []string) (*Provider, error) {
|
|
provider, err := oidc.NewProvider(ctx, GetIdpURL())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// If provided scopes are empty we use a default list
|
|
if len(scopes) == 0 {
|
|
scopes = []string{oidc.ScopeOpenID, "profile", "app_metadata", "user_metadata", "email"}
|
|
}
|
|
client := new(Provider)
|
|
config := xoauth2.Config{
|
|
ClientID: GetIdpClientID(),
|
|
ClientSecret: GetIdpSecret(),
|
|
RedirectURL: GetIdpCallbackURL(),
|
|
Endpoint: provider.Endpoint(),
|
|
Scopes: scopes,
|
|
}
|
|
client.oauth2Config = &config
|
|
client.oidcProvider = provider
|
|
client.ClientID = GetIdpClientID()
|
|
|
|
return client, nil
|
|
}
|
|
|
|
type User struct {
|
|
AppMetadata map[string]interface{} `json:"app_metadata"`
|
|
Blocked bool `json:"blocked"`
|
|
CreatedAt string `json:"created_at"`
|
|
Email string `json:"email"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
FamilyName string `json:"family_name"`
|
|
GivenName string `json:"given_name"`
|
|
Identities []interface{} `json:"identities"`
|
|
LastIP string `json:"last_ip"`
|
|
LastLogin string `json:"last_login"`
|
|
LastPasswordReset string `json:"last_password_reset"`
|
|
LoginsCount int `json:"logins_count"`
|
|
Mltifactor string `json:"multifactor"`
|
|
Name string `json:"name"`
|
|
Nickname string `json:"nickname"`
|
|
PhoneNumber string `json:"phone_number"`
|
|
PhoneVerified bool `json:"phone_verified"`
|
|
Picture string `json:"picture"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
UserID string `json:"user_id"`
|
|
UserMetadata map[string]interface{} `json:"user_metadata"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
// VerifyIdentity will contact the configured IDP and validate the user identity based on the authorization code
|
|
func (client *Provider) VerifyIdentity(ctx context.Context, code, state string) (*User, error) {
|
|
// verify the provided state is valid (prevents CSRF attacks)
|
|
if !validateOauth2State(state) {
|
|
return nil, errGeneric
|
|
}
|
|
// verify the authorization code against the identity oidcProvider
|
|
// idp will return a token in exchange
|
|
token, err := client.oauth2Config.Exchange(ctx, code)
|
|
if err != nil {
|
|
log.Println("Failed to verify authorization code", err)
|
|
return nil, errGeneric
|
|
}
|
|
// extract and check id_token field is provided in the response
|
|
rawIDToken, ok := token.Extra("id_token").(string)
|
|
if !ok {
|
|
log.Println("No id_token field in oauth2 token")
|
|
return nil, errGeneric
|
|
}
|
|
config := &oidc.Config{
|
|
ClientID: client.ClientID,
|
|
}
|
|
idToken, err := client.oidcProvider.Verifier(config).Verify(ctx, rawIDToken)
|
|
if err != nil {
|
|
log.Println("Failed to verify ID token", err)
|
|
return nil, errGeneric
|
|
}
|
|
var profile User
|
|
// Populate the profile object using the claims included in the token
|
|
if err := idToken.Claims(&profile); err != nil {
|
|
log.Println("Failed to read profile information", err)
|
|
return nil, errGeneric
|
|
}
|
|
return &profile, nil
|
|
}
|
|
|
|
// validateOauth2State validates the provided state was originated using the same
|
|
// instance (or one configured using the same secrets) of MCS, this is basically used to prevent CSRF attacks
|
|
// https://security.stackexchange.com/questions/20187/oauth2-cross-site-request-forgery-and-state-parameter
|
|
func validateOauth2State(state string) bool {
|
|
// state contains a base64 encoded string that may ends with "==", the browser encodes that to "%3D%3D"
|
|
// query unescape is need it before trying to decode the base64 string
|
|
encodedMessage, err := url.QueryUnescape(state)
|
|
if err != nil {
|
|
log.Println(err)
|
|
return false
|
|
}
|
|
// decode the state parameter value
|
|
message, err := base64.StdEncoding.DecodeString(encodedMessage)
|
|
if err != nil {
|
|
log.Println(err)
|
|
return false
|
|
}
|
|
s := strings.Split(string(message), ":")
|
|
// Validate that the decoded message has the right format "message:hmac"
|
|
if len(s) != 2 {
|
|
return false
|
|
}
|
|
// extract the state and hmac
|
|
incomingState, incomingHmac := s[0], s[1]
|
|
// validate that hmac(incomingState + pbkdf2(secret, salt)) == incomingHmac
|
|
return utils.ComputeHmac256(incomingState, derivedKey) == incomingHmac
|
|
}
|
|
|
|
// GetRandomStateWithHMAC computes message + hmac(message, pbkdf2(key, salt)) to be used as state during the oauth authorization
|
|
func GetRandomStateWithHMAC(length int) string {
|
|
state := utils.RandomCharString(length)
|
|
hmac := utils.ComputeHmac256(state, derivedKey)
|
|
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", state, hmac)))
|
|
}
|
|
|
|
// GenerateLoginURL returns a new login URL based on the configured IDP
|
|
func (client *Provider) GenerateLoginURL() string {
|
|
// generates random state and sign it using HMAC256
|
|
state := GetRandomStateWithHMAC(25)
|
|
loginURL := client.oauth2Config.AuthCodeURL(state)
|
|
return strings.TrimSpace(loginURL)
|
|
}
|