idp integration for mcs (#75)

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=""
```
This commit is contained in:
Lenin Alevski
2020-05-01 08:38:52 -07:00
committed by GitHub
parent f3d7e61ddb
commit 44d8e9b975
36 changed files with 2276 additions and 80 deletions

49
pkg/auth/idp.go Normal file
View File

@@ -0,0 +1,49 @@
// 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 auth
import (
"context"
"github.com/minio/mcs/pkg/auth/idp/oauth2"
)
// IdentityProviderClient interface with all functions to be implemented
// by mock when testing, it should include all IdentityProviderClient respective api calls
// that are used within this project.
type IdentityProviderClient interface {
VerifyIdentity(ctx context.Context, code, state string) (*oauth2.User, error)
GenerateLoginURL() string
}
// Interface implementation
//
// Define the structure of a IdentityProvider Client and define the functions that are actually used
// during the authentication flow.
type IdentityProvider struct {
Client IdentityProviderClient
}
// VerifyIdentity will verify the user identity against the idp using the authorization code flow
func (c IdentityProvider) VerifyIdentity(ctx context.Context, code, state string) (*oauth2.User, error) {
return c.Client.VerifyIdentity(ctx, code, state)
}
// GenerateLoginURL returns a new URL used by the user to login against the idp
func (c IdentityProvider) GenerateLoginURL() string {
return c.Client.GenerateLoginURL()
}

View File

@@ -0,0 +1,71 @@
// 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 contains all the necessary configurations to initialize the
// idp communication using oauth2 protocol
package oauth2
import (
"github.com/minio/mcs/pkg/auth/utils"
"github.com/minio/minio/pkg/env"
)
func GetIdpURL() string {
return env.Get(McsIdpURL, "")
}
func GetIdpClientID() string {
return env.Get(McsIdpClientID, "")
}
func GetIdpSecret() string {
return env.Get(McsIdpSecret, "")
}
// Public endpoint used by the identity oidcProvider when redirecting the user after identity verification
func GetIdpCallbackURL() string {
return env.Get(McsIdpCallbackURL, "")
}
func GetIdpAdminRoles() string {
return env.Get(McsIdpAdminRoles, "")
}
func IsIdpEnabled() bool {
return GetIdpURL() != "" &&
GetIdpClientID() != "" &&
GetIdpSecret() != "" &&
GetIdpCallbackURL() != ""
}
var defaultPassphraseForIdpHmac = utils.RandomCharString(64)
// GetPassphraseForIdpHmac returns passphrase for the pbkdf2 function used to sign the oauth2 state parameter
func getPassphraseForIdpHmac() string {
return env.Get(McsIdpHmacPassphrase, defaultPassphraseForIdpHmac)
}
var defaultSaltForIdpHmac = utils.RandomCharString(64)
// GetSaltForIdpHmac returns salt for the pbkdf2 function used to sign the oauth2 state parameter
func getSaltForIdpHmac() string {
return env.Get(McsIdpHmacSalt, defaultSaltForIdpHmac)
}
// GetSaltForIdpHmac returns the policy to be assigned to the users authenticating via an IDP
func GetIDPPolicyForUser() string {
return env.Get(McsIdpPolicyUser, "mcsAdmin")
}

View File

@@ -0,0 +1,29 @@
// 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
const (
// const for idp configuration
McsIdpURL = "MCS_IDP_URL"
McsIdpClientID = "MCS_IDP_CLIENT_ID"
McsIdpSecret = "MCS_IDP_SECRET"
McsIdpCallbackURL = "MCS_IDP_CALLBACK"
McsIdpAdminRoles = "MCS_IDP_ADMIN_ROLES"
McsIdpHmacPassphrase = "MCS_IDP_HMAC_PASSPHRASE"
McsIdpHmacSalt = "MCS_IDP_HMAC_SALT"
McsIdpPolicyUser = "MCS_IDP_POLICY_USER"
)

View File

@@ -0,0 +1,229 @@
// 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)
}

View File

@@ -0,0 +1,98 @@
// 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"
"net/http"
"testing"
"github.com/coreos/go-oidc"
"github.com/stretchr/testify/assert"
"golang.org/x/oauth2"
)
type Oauth2configMock struct{}
var oauth2ConfigExchangeMock func(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
var oauth2ConfigAuthCodeURLMock func(state string, opts ...oauth2.AuthCodeOption) string
var oauth2ConfigPasswordCredentialsTokenMock func(ctx context.Context, username string, password string) (*oauth2.Token, error)
var oauth2ConfigClientMock func(ctx context.Context, t *oauth2.Token) *http.Client
var oauth2ConfigokenSourceMock func(ctx context.Context, t *oauth2.Token) oauth2.TokenSource
func (ac Oauth2configMock) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
return oauth2ConfigExchangeMock(ctx, code, opts...)
}
func (ac Oauth2configMock) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
return oauth2ConfigAuthCodeURLMock(state, opts...)
}
func (ac Oauth2configMock) PasswordCredentialsToken(ctx context.Context, username string, password string) (*oauth2.Token, error) {
return oauth2ConfigPasswordCredentialsTokenMock(ctx, username, password)
}
func (ac Oauth2configMock) Client(ctx context.Context, t *oauth2.Token) *http.Client {
return oauth2ConfigClientMock(ctx, t)
}
func (ac Oauth2configMock) TokenSource(ctx context.Context, t *oauth2.Token) oauth2.TokenSource {
return oauth2ConfigokenSourceMock(ctx, t)
}
func TestGenerateLoginURL(t *testing.T) {
funcAssert := assert.New(t)
oauth2Provider := Provider{
oauth2Config: Oauth2configMock{},
oidcProvider: &oidc.Provider{},
}
// Test-1 : GenerateLoginURL() generates URL correctly with provided state
oauth2ConfigAuthCodeURLMock = func(state string, opts ...oauth2.AuthCodeOption) string {
// Internally we are testing the private method getRandomStateWithHMAC, this function should always returns
// a non-empty string
return state
}
url := oauth2Provider.GenerateLoginURL()
funcAssert.NotEqual("", url)
}
func TestVerifyIdentity(t *testing.T) {
ctx := context.Background()
funcAssert := assert.New(t)
// mock data
oauth2Provider := Provider{
oauth2Config: Oauth2configMock{},
oidcProvider: &oidc.Provider{},
}
// Test-1 : VerifyIdentity() should fail because of bad state token
_, err := oauth2Provider.VerifyIdentity(ctx, "AAABBBCCCDDDEEEFFF", "badtoken")
funcAssert.NotNil(err)
// Test-2 : VerifyIdentity() should fail because no id_token is provided by the idp
oauth2ConfigExchangeMock = func(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
return &oauth2.Token{}, nil
}
state := GetRandomStateWithHMAC(32)
code := "AAABBBCCCDDDEEEFFF"
_, err = oauth2Provider.VerifyIdentity(ctx, code, state)
funcAssert.NotNil(err)
// Test-3 : VerifyIdentity() should fail because no id_token is provided by the idp
// TODO
// Test-4 : VerifyIdentity() should fail because oidcProvider.Verifier returned an error
// TODO
// Test-5 : VerifyIdentity() should fail because idToken.Claims contains invalid fields
// TODO
}

View File

@@ -17,45 +17,15 @@
package jwt
import (
"crypto/rand"
"io"
"strconv"
"strings"
"time"
"github.com/minio/mcs/pkg/auth/utils"
"github.com/minio/minio/pkg/env"
)
// Do not use:
// https://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-go
// It relies on math/rand and therefore not on a cryptographically secure RNG => It must not be used
// for access/secret keys.
// The alphabet of random character string. Each character must be unique.
//
// The RandomCharString implementation requires that: 256 / len(letters) is a natural numbers.
// For example: 256 / 64 = 4. However, 5 > 256/62 > 4 and therefore we must not use a alphabet
// of 62 characters.
// The reason is that if 256 / len(letters) is not a natural number then certain characters become
// more likely then others.
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345"
func RandomCharString(n int) string {
random := make([]byte, n)
if _, err := io.ReadFull(rand.Reader, random); err != nil {
panic(err) // Can only happen if we would run out of entropy.
}
var s strings.Builder
for _, v := range random {
j := v % byte(len(letters))
s.WriteByte(letters[j])
}
return s.String()
}
// defaultHmacJWTPassphrase will be used by default if application is not configured with a custom MCS_HMAC_JWT_SECRET secret
var defaultHmacJWTPassphrase = RandomCharString(64)
var defaultHmacJWTPassphrase = utils.RandomCharString(64)
// GetHmacJWTSecret returns the 64 bytes secret used for signing the generated JWT for the application
func GetHmacJWTSecret() string {
@@ -78,15 +48,14 @@ func GetMcsSTSAndJWTDurationTime() time.Duration {
return time.Duration(duration) * time.Second
}
// defaultPBKDFPassphrase
var defaultPBKDFPassphrase = RandomCharString(64)
var defaultPBKDFPassphrase = utils.RandomCharString(64)
// GetPBKDFPassphrase returns passphrase for the pbkdf2 function used to encrypt JWT payload
func GetPBKDFPassphrase() string {
return env.Get(McsPBKDFPassphrase, defaultPBKDFPassphrase)
}
var defaultPBKDFSalt = RandomCharString(64)
var defaultPBKDFSalt = utils.RandomCharString(64)
// GetPBKDFSalt returns salt for the pbkdf2 function used to encrypt JWT payload
func GetPBKDFSalt() string {

60
pkg/auth/utils/utils.go Normal file
View File

@@ -0,0 +1,60 @@
// 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 utils
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"io"
"strings"
)
// Do not use:
// https://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-go
// It relies on math/rand and therefore not on a cryptographically secure RNG => It must not be used
// for access/secret keys.
// The alphabet of random character string. Each character must be unique.
//
// The RandomCharString implementation requires that: 256 / len(letters) is a natural numbers.
// For example: 256 / 64 = 4. However, 5 > 256/62 > 4 and therefore we must not use a alphabet
// of 62 characters.
// The reason is that if 256 / len(letters) is not a natural number then certain characters become
// more likely then others.
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ012345"
func RandomCharString(n int) string {
random := make([]byte, n)
if _, err := io.ReadFull(rand.Reader, random); err != nil {
panic(err) // Can only happen if we would run out of entropy.
}
var s strings.Builder
for _, v := range random {
j := v % byte(len(letters))
s.WriteByte(letters[j])
}
return s.String()
}
func ComputeHmac256(message string, key []byte) string {
h := hmac.New(sha256.New, key)
h.Write([]byte(message))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

View File

@@ -0,0 +1,46 @@
// 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 utils
import (
"crypto/sha1"
"testing"
"github.com/stretchr/testify/assert"
"golang.org/x/crypto/pbkdf2"
)
func TestRandomCharString(t *testing.T) {
funcAssert := assert.New(t)
// Test-1 : RandomCharString() should return string with expected length
length := 32
token := RandomCharString(length)
funcAssert.Equal(length, len(token))
// Test-2 : RandomCharString() should output random string, new generated string should not be equal to the previous one
newToken := RandomCharString(length)
funcAssert.NotEqual(token, newToken)
}
func TestComputeHmac256(t *testing.T) {
funcAssert := assert.New(t)
// Test-1 : ComputeHmac256() should return the right Hmac256 string based on a derived key
var derivedKey = pbkdf2.Key([]byte("secret"), []byte("salt"), 4096, 32, sha1.New)
var message = "hello world"
var expectedHmac = "5r32q7W+0hcBnqzQwJJUDzVGoVivXGSodTcHSqG/9Q8="
hmac := ComputeHmac256(message, derivedKey)
funcAssert.Equal(hmac, expectedHmac)
}