feat: Add support to poll users on external SSO (#12592)

Additional support for vendor-specific admin API
integrations for OpenID, to ensure validity of
credentials on MinIO.

Every 5minutes check for validity of credentials
on MinIO with vendor specific IDP.
This commit is contained in:
Harshavardhana
2021-07-09 11:17:21 -07:00
committed by GitHub
parent b79cdc1611
commit 28adb29db3
10 changed files with 494 additions and 68 deletions

View File

@@ -62,6 +62,24 @@ var (
Optional: true,
Type: "csv",
},
config.HelpKV{
Key: Vendor,
Description: `Specify vendor type for vendor specific behavior to checking validity of temporary credentials and service accounts on MinIO`,
Optional: true,
Type: "string",
},
config.HelpKV{
Key: KeyCloakRealm,
Description: `Specify Keycloak 'realm' name, only honored if vendor was set to 'keycloak' as value, if no realm is specified 'master' is default`,
Optional: true,
Type: "string",
},
config.HelpKV{
Key: KeyCloakAdminURL,
Description: `Specify Keycloak 'admin' REST API endpoint e.g. http://localhost:8080/auth/admin/`,
Optional: true,
Type: "string",
},
config.HelpKV{
Key: config.Comment,
Description: config.DefaultComment,

View File

@@ -32,6 +32,7 @@ import (
jwtgo "github.com/golang-jwt/jwt"
"github.com/minio/minio/internal/auth"
"github.com/minio/minio/internal/config"
"github.com/minio/minio/internal/config/identity/openid/provider"
"github.com/minio/pkg/env"
iampolicy "github.com/minio/pkg/iam/policy"
xnet "github.com/minio/pkg/net"
@@ -50,12 +51,77 @@ type Config struct {
DiscoveryDoc DiscoveryDoc
ClientID string
ClientSecret string
provider provider.Provider
publicKeys map[string]crypto.PublicKey
transport *http.Transport
closeRespFn func(io.ReadCloser)
mutex *sync.Mutex
}
// LookupUser lookup userid for the provider
func (r *Config) LookupUser(userid string) (provider.User, error) {
if r.provider != nil {
user, err := r.provider.LookupUser(userid)
if err != nil && err != provider.ErrAccessTokenExpired {
return user, err
}
if err == provider.ErrAccessTokenExpired {
if err = r.provider.LoginWithClientID(r.ClientID, r.ClientSecret); err != nil {
return user, err
}
user, err = r.provider.LookupUser(userid)
}
return user, err
}
// Without any specific logic for a provider, all accounts
// are always enabled.
return provider.User{ID: userid, Enabled: true}, nil
}
const (
keyCloakVendor = "keycloak"
)
// InitializeProvider initializes if any additional vendor specific
// information was provided, initialization will return an error
// initial login fails.
func (r *Config) InitializeProvider(kvs config.KVS) error {
vendor := env.Get(EnvIdentityOpenIDVendor, kvs.Get(Vendor))
if vendor == "" {
return nil
}
switch vendor {
case keyCloakVendor:
adminURL := env.Get(EnvIdentityOpenIDKeyCloakAdminURL, kvs.Get(KeyCloakAdminURL))
realm := env.Get(EnvIdentityOpenIDKeyCloakRealm, kvs.Get(KeyCloakRealm))
return r.InitializeKeycloakProvider(adminURL, realm)
default:
return fmt.Errorf("Unsupport vendor %s", keyCloakVendor)
}
}
// ProviderEnabled returns true if any vendor specific provider is enabled.
func (r *Config) ProviderEnabled() bool {
r.mutex.Lock()
defer r.mutex.Unlock()
return r.provider != nil
}
// InitializeKeycloakProvider - initializes keycloak provider
func (r *Config) InitializeKeycloakProvider(adminURL, realm string) error {
r.mutex.Lock()
defer r.mutex.Unlock()
var err error
r.provider, err = provider.KeyCloak(
provider.WithAdminURL(adminURL),
provider.WithOpenIDConfig(provider.DiscoveryDoc(r.DiscoveryDoc)),
provider.WithTransport(r.transport),
provider.WithRealm(realm),
)
return err
}
// PopulatePublicKey - populates a new publickey from the JWKS URL.
func (r *Config) PopulatePublicKey() error {
r.mutex.Lock()
@@ -228,9 +294,15 @@ const (
ClaimPrefix = "claim_prefix"
ClientID = "client_id"
ClientSecret = "client_secret"
Vendor = "vendor"
Scopes = "scopes"
RedirectURI = "redirect_uri"
// Vendor specific ENV only enabled if the Vendor matches == "vendor"
KeyCloakRealm = "keycloak_realm"
KeyCloakAdminURL = "keycloak_admin_url"
EnvIdentityOpenIDVendor = "MINIO_IDENTITY_OPENID_VENDOR"
EnvIdentityOpenIDClientID = "MINIO_IDENTITY_OPENID_CLIENT_ID"
EnvIdentityOpenIDClientSecret = "MINIO_IDENTITY_OPENID_CLIENT_SECRET"
EnvIdentityOpenIDJWKSURL = "MINIO_IDENTITY_OPENID_JWKS_URL"
@@ -239,6 +311,10 @@ const (
EnvIdentityOpenIDClaimPrefix = "MINIO_IDENTITY_OPENID_CLAIM_PREFIX"
EnvIdentityOpenIDRedirectURI = "MINIO_IDENTITY_OPENID_REDIRECT_URI"
EnvIdentityOpenIDScopes = "MINIO_IDENTITY_OPENID_SCOPES"
// Vendor specific ENVs only enabled if the Vendor matches == "vendor"
EnvIdentityOpenIDKeyCloakRealm = "MINIO_IDENTITY_OPENID_KEYCLOAK_REALM"
EnvIdentityOpenIDKeyCloakAdminURL = "MINIO_IDENTITY_OPENID_KEYCLOAK_ADMIN_URL"
)
// DiscoveryDoc - parses the output from openid-configuration
@@ -397,6 +473,10 @@ func LookupConfig(kvs config.KVS, transport *http.Transport, closeRespFn func(io
return c, err
}
if err = c.InitializeProvider(kvs); err != nil {
return c, err
}
return c, nil
}

View File

@@ -0,0 +1,184 @@
// Copyright (c) 2015-2021 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// 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 provider
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
)
// Token - parses the output from IDP access token.
type Token struct {
AccessToken string `json:"access_token"`
Expiry int `json:"expires_in"`
}
// KeycloakProvider implements Provider interface for KeyCloak Identity Provider.
type KeycloakProvider struct {
sync.Mutex
oeConfig DiscoveryDoc
client http.Client
adminURL string
realm string
// internal value refreshed
accessToken Token
}
// LoginWithUser authenticates username/password, not needed for Keycloak
func (k *KeycloakProvider) LoginWithUser(username, password string) error {
return ErrNotImplemented
}
// LoginWithClientID is implemented by Keycloak service account support
func (k *KeycloakProvider) LoginWithClientID(clientID, clientSecret string) error {
values := url.Values{}
values.Set("client_id", clientID)
values.Set("client_secret", clientSecret)
values.Set("grant_type", "client_credentials")
req, err := http.NewRequest(http.MethodPost, k.oeConfig.TokenEndpoint, strings.NewReader(values.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := k.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var accessToken Token
if err = json.NewDecoder(resp.Body).Decode(&accessToken); err != nil {
return err
}
k.Lock()
k.accessToken = accessToken
k.Unlock()
return nil
}
// LookupUser lookup user by their userid.
func (k *KeycloakProvider) LookupUser(userid string) (User, error) {
lookupUserID := k.adminURL + "/realms" + k.realm + "/users/" + userid
req, err := http.NewRequest(http.MethodGet, lookupUserID, nil)
if err != nil {
return User{}, err
}
k.Lock()
accessToken := k.accessToken
k.Unlock()
if accessToken.AccessToken == "" {
return User{}, ErrAccessTokenExpired
}
req.Header.Set("Authorization", "Bearer "+accessToken.AccessToken)
resp, err := k.client.Do(req)
if err != nil {
return User{}, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK, http.StatusPartialContent:
var u User
if err = json.NewDecoder(resp.Body).Decode(&u); err != nil {
return User{}, err
}
return u, nil
case http.StatusNotFound:
return User{
ID: userid,
Enabled: false,
}, nil
case http.StatusUnauthorized:
return User{}, ErrAccessTokenExpired
}
return User{}, fmt.Errorf("Unable to lookup %s - keycloak user lookup returned %v", userid, resp.Status)
}
// Option is a function type that accepts a pointer Target
type Option func(*KeycloakProvider)
// WithTransport provide custom transport
func WithTransport(transport *http.Transport) Option {
return func(p *KeycloakProvider) {
p.client = http.Client{
Transport: transport,
}
}
}
// WithOpenIDConfig provide OpenID Endpoint configuration discovery document
func WithOpenIDConfig(oeConfig DiscoveryDoc) Option {
return func(p *KeycloakProvider) {
p.oeConfig = oeConfig
}
}
// WithAdminURL provide admin URL configuration for Keycloak
func WithAdminURL(url string) Option {
return func(p *KeycloakProvider) {
p.adminURL = url
}
}
// WithRealm provide realm configuration for Keycloak
func WithRealm(realm string) Option {
return func(p *KeycloakProvider) {
p.realm = realm
}
}
// KeyCloak initializes a new keycloak provider
func KeyCloak(opts ...Option) (Provider, error) {
p := &KeycloakProvider{}
for _, opt := range opts {
opt(p)
}
if p.adminURL == "" {
return nil, errors.New("Admin URL cannot be empty")
}
_, err := url.Parse(p.adminURL)
if err != nil {
return nil, fmt.Errorf("Unable to parse the adminURL %s: %w", p.adminURL, err)
}
if p.client.Transport == nil {
p.client.Transport = http.DefaultTransport
}
if p.oeConfig.TokenEndpoint == "" {
return nil, errors.New("missing OpenID token endpoint")
}
if p.realm == "" {
p.realm = "master" // default realm
}
return p, nil
}

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2015-2021 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// 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 provider
import "errors"
// DiscoveryDoc - parses the output from openid-configuration
// for example https://accounts.google.com/.well-known/openid-configuration
type DiscoveryDoc struct {
Issuer string `json:"issuer,omitempty"`
AuthEndpoint string `json:"authorization_endpoint,omitempty"`
TokenEndpoint string `json:"token_endpoint,omitempty"`
UserInfoEndpoint string `json:"userinfo_endpoint,omitempty"`
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
JwksURI string `json:"jwks_uri,omitempty"`
ResponseTypesSupported []string `json:"response_types_supported,omitempty"`
SubjectTypesSupported []string `json:"subject_types_supported,omitempty"`
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported,omitempty"`
ScopesSupported []string `json:"scopes_supported,omitempty"`
TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported,omitempty"`
ClaimsSupported []string `json:"claims_supported,omitempty"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"`
}
// User represents information about user.
type User struct {
Name string `json:"username"`
ID string `json:"id"`
Enabled bool `json:"enabled"`
}
// Standard errors.
var (
ErrNotImplemented = errors.New("function not implemented")
ErrAccessTokenExpired = errors.New("access_token expired or unauthorized")
)
// Provider implements indentity provider specific admin operations, such as
// looking up users, fetching additional attributes etc.
type Provider interface {
LoginWithUser(username, password string) error
LoginWithClientID(clientID, clientSecret string) error
LookupUser(userid string) (User, error)
}