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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 494 additions and 68 deletions

View File

@ -162,7 +162,7 @@ func (ies *IAMEtcdStore) migrateUsersConfigToV1(ctx context.Context) error {
// 2. copy policy to new loc.
mp := newMappedPolicy(policyName)
userType := regularUser
userType := regUser
path := getMappedPolicyPath(user, userType, false)
if err := ies.saveIAMConfig(ctx, mp, path); err != nil {
return err
@ -335,7 +335,7 @@ func (ies *IAMEtcdStore) loadUser(ctx context.Context, user string, userType IAM
func (ies *IAMEtcdStore) loadUsers(ctx context.Context, userType IAMUserType, m map[string]auth.Credentials) error {
var basePrefix string
switch userType {
case srvAccUser:
case svcUser:
basePrefix = iamConfigServiceAccountsPrefix
case stsUser:
basePrefix = iamConfigSTSPrefix
@ -432,7 +432,7 @@ func (ies *IAMEtcdStore) loadMappedPolicies(ctx context.Context, userType IAMUse
basePrefix = iamConfigPolicyDBGroupsPrefix
} else {
switch userType {
case srvAccUser:
case svcUser:
basePrefix = iamConfigPolicyDBServiceAccountsPrefix
case stsUser:
basePrefix = iamConfigPolicyDBSTSUsersPrefix
@ -567,7 +567,7 @@ func (ies *IAMEtcdStore) reloadFromEvent(sys *IAMSys, event *etcd.Event) {
case usersPrefix:
accessKey := path.Dir(strings.TrimPrefix(string(event.Kv.Key),
iamConfigUsersPrefix))
ies.loadUser(ctx, accessKey, regularUser, sys.iamUsersMap)
ies.loadUser(ctx, accessKey, regUser, sys.iamUsersMap)
case stsPrefix:
accessKey := path.Dir(strings.TrimPrefix(string(event.Kv.Key),
iamConfigSTSPrefix))
@ -593,7 +593,7 @@ func (ies *IAMEtcdStore) reloadFromEvent(sys *IAMSys, event *etcd.Event) {
case svcPrefix:
accessKey := path.Dir(strings.TrimPrefix(string(event.Kv.Key),
iamConfigServiceAccountsPrefix))
ies.loadUser(ctx, accessKey, srvAccUser, sys.iamUsersMap)
ies.loadUser(ctx, accessKey, svcUser, sys.iamUsersMap)
case groupsPrefix:
group := path.Dir(strings.TrimPrefix(string(event.Kv.Key),
iamConfigGroupsPrefix))
@ -609,7 +609,7 @@ func (ies *IAMEtcdStore) reloadFromEvent(sys *IAMSys, event *etcd.Event) {
policyMapFile := strings.TrimPrefix(string(event.Kv.Key),
iamConfigPolicyDBUsersPrefix)
user := strings.TrimSuffix(policyMapFile, ".json")
ies.loadMappedPolicy(ctx, user, regularUser, false, sys.iamUserPolicyMap)
ies.loadMappedPolicy(ctx, user, regUser, false, sys.iamUserPolicyMap)
case policyDBSTSUsersPrefix:
policyMapFile := strings.TrimPrefix(string(event.Kv.Key),
iamConfigPolicyDBSTSUsersPrefix)
@ -619,7 +619,7 @@ func (ies *IAMEtcdStore) reloadFromEvent(sys *IAMSys, event *etcd.Event) {
policyMapFile := strings.TrimPrefix(string(event.Kv.Key),
iamConfigPolicyDBGroupsPrefix)
user := strings.TrimSuffix(policyMapFile, ".json")
ies.loadMappedPolicy(ctx, user, regularUser, true, sys.iamGroupPolicyMap)
ies.loadMappedPolicy(ctx, user, regUser, true, sys.iamGroupPolicyMap)
}
case eventDelete:
switch {

View File

@ -104,7 +104,7 @@ func (iamOS *IAMObjectStore) migrateUsersConfigToV1(ctx context.Context) error {
// 2. copy policy file to new location.
mp := newMappedPolicy(policyName)
userType := regularUser
userType := regUser
if err := iamOS.saveMappedPolicy(ctx, user, userType, false, mp); err != nil {
return err
}
@ -279,7 +279,7 @@ func (iamOS *IAMObjectStore) loadUser(ctx context.Context, user string, userType
func (iamOS *IAMObjectStore) loadUsers(ctx context.Context, userType IAMUserType, m map[string]auth.Credentials) error {
var basePrefix string
switch userType {
case srvAccUser:
case svcUser:
basePrefix = iamConfigServiceAccountsPrefix
case stsUser:
basePrefix = iamConfigSTSPrefix
@ -348,7 +348,7 @@ func (iamOS *IAMObjectStore) loadMappedPolicies(ctx context.Context, userType IA
basePath = iamConfigPolicyDBGroupsPrefix
} else {
switch userType {
case srvAccUser:
case svcUser:
basePath = iamConfigPolicyDBServiceAccountsPrefix
case stsUser:
basePath = iamConfigPolicyDBSTSUsersPrefix

View File

@ -112,7 +112,7 @@ func getIAMFormatFilePath() string {
func getUserIdentityPath(user string, userType IAMUserType) string {
var basePath string
switch userType {
case srvAccUser:
case svcUser:
basePath = iamConfigServiceAccountsPrefix
case stsUser:
basePath = iamConfigSTSPrefix
@ -135,7 +135,7 @@ func getMappedPolicyPath(name string, userType IAMUserType, isGroup bool) string
return pathJoin(iamConfigPolicyDBGroupsPrefix, name+".json")
}
switch userType {
case srvAccUser:
case svcUser:
return pathJoin(iamConfigPolicyDBServiceAccountsPrefix, name+".json")
case stsUser:
return pathJoin(iamConfigPolicyDBSTSUsersPrefix, name+".json")
@ -230,9 +230,9 @@ type IAMSys struct {
type IAMUserType int
const (
regularUser IAMUserType = iota
regUser IAMUserType = iota
stsUser
srvAccUser
svcUser
)
// key options
@ -355,7 +355,7 @@ func (sys *IAMSys) LoadPolicyMapping(objAPI ObjectLayer, userOrGroup string, isG
if globalEtcdClient == nil {
var err error
userType := regularUser
userType := regUser
if sys.usersSysType == LDAPUsersSysType {
userType = stsUser
}
@ -432,7 +432,7 @@ func (sys *IAMSys) LoadServiceAccount(accessKey string) error {
defer sys.store.unlock()
if globalEtcdClient == nil {
err := sys.store.loadUser(context.Background(), accessKey, srvAccUser, sys.iamUsersMap)
err := sys.store.loadUser(context.Background(), accessKey, svcUser, sys.iamUsersMap)
if err != nil {
return err
}
@ -492,7 +492,7 @@ func (sys *IAMSys) Load(ctx context.Context, store IAMStorageAPI) error {
setDefaultCannedPolicies(iamPolicyDocsMap)
if isMinIOUsersSys {
if err := store.loadUsers(ctx, regularUser, iamUsersMap); err != nil {
if err := store.loadUsers(ctx, regUser, iamUsersMap); err != nil {
return err
}
if err := store.loadGroups(ctx, iamGroupsMap); err != nil {
@ -501,16 +501,16 @@ func (sys *IAMSys) Load(ctx context.Context, store IAMStorageAPI) error {
}
// load polices mapped to users
if err := store.loadMappedPolicies(ctx, regularUser, false, iamUserPolicyMap); err != nil {
if err := store.loadMappedPolicies(ctx, regUser, false, iamUserPolicyMap); err != nil {
return err
}
// load policies mapped to groups
if err := store.loadMappedPolicies(ctx, regularUser, true, iamGroupPolicyMap); err != nil {
if err := store.loadMappedPolicies(ctx, regUser, true, iamGroupPolicyMap); err != nil {
return err
}
if err := store.loadUsers(ctx, srvAccUser, iamUsersMap); err != nil {
if err := store.loadUsers(ctx, svcUser, iamUsersMap); err != nil {
return err
}
@ -545,33 +545,11 @@ func (sys *IAMSys) Load(ctx context.Context, store IAMStorageAPI) error {
}
// purge any expired entries which became expired now.
var expiredEntries []string
for k, v := range sys.iamUsersMap {
if v.IsExpired() {
delete(sys.iamUsersMap, k)
delete(sys.iamUserPolicyMap, k)
expiredEntries = append(expiredEntries, k)
// Deleting on the disk is taken care of in the next cycle
}
}
for _, v := range sys.iamUsersMap {
if v.IsServiceAccount() {
for _, accessKey := range expiredEntries {
if v.ParentUser == accessKey {
_ = store.deleteUserIdentity(ctx, v.AccessKey, srvAccUser)
delete(sys.iamUsersMap, v.AccessKey)
}
}
}
}
// purge any expired entries which became expired now.
for k, v := range sys.iamUsersMap {
if v.IsExpired() {
delete(sys.iamUsersMap, k)
delete(sys.iamUserPolicyMap, k)
// Deleting on the etcd is taken care of in the next cycle
// deleting will be done in the next cycle.
}
}
@ -668,6 +646,16 @@ func (sys *IAMSys) Init(ctx context.Context, objAPI ObjectLayer) {
break
}
if globalOpenIDConfig.ProviderEnabled() {
go func() {
// Purge expired credentials
for {
time.Sleep(globalRefreshIAMInterval)
sys.purgeExpiredCredentialsForExternalSSO(ctx)
}
}()
}
go sys.store.watch(ctx, sys)
}
@ -708,7 +696,7 @@ func (sys *IAMSys) DeletePolicy(policyName string) error {
if cr.IsTemp() {
sys.policyDBSet(u, strings.Join(pset.ToSlice(), ","), stsUser, false)
} else {
sys.policyDBSet(u, strings.Join(pset.ToSlice(), ","), regularUser, false)
sys.policyDBSet(u, strings.Join(pset.ToSlice(), ","), regUser, false)
}
}
}
@ -718,7 +706,7 @@ func (sys *IAMSys) DeletePolicy(policyName string) error {
pset := mp.policySet()
if pset.Contains(policyName) {
pset.Remove(policyName)
sys.policyDBSet(g, strings.Join(pset.ToSlice(), ","), regularUser, true)
sys.policyDBSet(g, strings.Join(pset.ToSlice(), ","), regUser, true)
}
}
@ -823,7 +811,7 @@ func (sys *IAMSys) DeleteUser(accessKey string) error {
// Delete any service accounts if any first.
if u.IsServiceAccount() {
if u.ParentUser == accessKey {
_ = sys.store.deleteUserIdentity(context.Background(), u.AccessKey, srvAccUser)
_ = sys.store.deleteUserIdentity(context.Background(), u.AccessKey, svcUser)
delete(sys.iamUsersMap, u.AccessKey)
}
}
@ -837,8 +825,8 @@ func (sys *IAMSys) DeleteUser(accessKey string) error {
}
// It is ok to ignore deletion error on the mapped policy
sys.store.deleteMappedPolicy(context.Background(), accessKey, regularUser, false)
err := sys.store.deleteUserIdentity(context.Background(), accessKey, regularUser)
sys.store.deleteMappedPolicy(context.Background(), accessKey, regUser, false)
err := sys.store.deleteUserIdentity(context.Background(), accessKey, regUser)
if err == errNoSuchUser {
// ignore if user is already deleted.
err = nil
@ -1153,7 +1141,7 @@ func (sys *IAMSys) SetUserStatus(accessKey string, status madmin.AccountStatus)
}(),
})
if err := sys.store.saveUserIdentity(context.Background(), accessKey, regularUser, uinfo); err != nil {
if err := sys.store.saveUserIdentity(context.Background(), accessKey, regUser, uinfo); err != nil {
return err
}
@ -1240,7 +1228,7 @@ func (sys *IAMSys) NewServiceAccount(ctx context.Context, parentUser string, gro
u := newUserIdentity(cred)
if err := sys.store.saveUserIdentity(context.Background(), u.Credentials.AccessKey, srvAccUser, u); err != nil {
if err := sys.store.saveUserIdentity(context.Background(), u.Credentials.AccessKey, svcUser, u); err != nil {
return auth.Credentials{}, err
}
@ -1310,7 +1298,7 @@ func (sys *IAMSys) UpdateServiceAccount(ctx context.Context, accessKey string, o
}
u := newUserIdentity(cr)
if err := sys.store.saveUserIdentity(context.Background(), u.Credentials.AccessKey, srvAccUser, u); err != nil {
if err := sys.store.saveUserIdentity(context.Background(), u.Credentials.AccessKey, svcUser, u); err != nil {
return err
}
@ -1397,7 +1385,7 @@ func (sys *IAMSys) DeleteServiceAccount(ctx context.Context, accessKey string) e
}
// It is ok to ignore deletion error on the mapped policy
err := sys.store.deleteUserIdentity(context.Background(), accessKey, srvAccUser)
err := sys.store.deleteUserIdentity(context.Background(), accessKey, svcUser)
if err != nil {
// ignore if user is already deleted.
if err == errNoSuchUser {
@ -1448,7 +1436,7 @@ func (sys *IAMSys) CreateUser(accessKey string, uinfo madmin.UserInfo) error {
}(),
})
if err := sys.store.saveUserIdentity(context.Background(), accessKey, regularUser, u); err != nil {
if err := sys.store.saveUserIdentity(context.Background(), accessKey, regUser, u); err != nil {
return err
}
@ -1456,7 +1444,7 @@ func (sys *IAMSys) CreateUser(accessKey string, uinfo madmin.UserInfo) error {
// Set policy if specified.
if uinfo.PolicyName != "" {
return sys.policyDBSet(accessKey, uinfo.PolicyName, regularUser, false)
return sys.policyDBSet(accessKey, uinfo.PolicyName, regUser, false)
}
return nil
}
@ -1489,7 +1477,7 @@ func (sys *IAMSys) SetUserSecretKey(accessKey string, secretKey string) error {
cred.SecretKey = secretKey
u := newUserIdentity(cred)
if err := sys.store.saveUserIdentity(context.Background(), accessKey, regularUser, u); err != nil {
if err := sys.store.saveUserIdentity(context.Background(), accessKey, regUser, u); err != nil {
return err
}
@ -1501,18 +1489,18 @@ func (sys *IAMSys) loadUserFromStore(accessKey string) {
sys.store.lock()
// If user is already found proceed.
if _, found := sys.iamUsersMap[accessKey]; !found {
sys.store.loadUser(context.Background(), accessKey, regularUser, sys.iamUsersMap)
sys.store.loadUser(context.Background(), accessKey, regUser, sys.iamUsersMap)
if _, found = sys.iamUsersMap[accessKey]; found {
// found user, load its mapped policies
sys.store.loadMappedPolicy(context.Background(), accessKey, regularUser, false, sys.iamUserPolicyMap)
sys.store.loadMappedPolicy(context.Background(), accessKey, regUser, false, sys.iamUserPolicyMap)
} else {
sys.store.loadUser(context.Background(), accessKey, srvAccUser, sys.iamUsersMap)
sys.store.loadUser(context.Background(), accessKey, svcUser, sys.iamUsersMap)
if svc, found := sys.iamUsersMap[accessKey]; found {
// Found service account, load its parent user and its mapped policies.
if sys.usersSysType == MinIOUsersSysType {
sys.store.loadUser(context.Background(), svc.ParentUser, regularUser, sys.iamUsersMap)
sys.store.loadUser(context.Background(), svc.ParentUser, regUser, sys.iamUsersMap)
}
sys.store.loadMappedPolicy(context.Background(), svc.ParentUser, regularUser, false, sys.iamUserPolicyMap)
sys.store.loadMappedPolicy(context.Background(), svc.ParentUser, regUser, false, sys.iamUserPolicyMap)
} else {
// None found fall back to STS users.
sys.store.loadUser(context.Background(), accessKey, stsUser, sys.iamUsersMap)
@ -1535,6 +1523,54 @@ func (sys *IAMSys) loadUserFromStore(accessKey string) {
sys.store.unlock()
}
// purgeExpiredCredentialsForExternalSSO - validates if local credentials are still valid
// by checking remote IDP if the relevant users are still active and present.
func (sys *IAMSys) purgeExpiredCredentialsForExternalSSO(ctx context.Context) {
sys.store.lock()
parentUsersMap := make(map[string]auth.Credentials, len(sys.iamUsersMap))
for _, cred := range sys.iamUsersMap {
if cred.IsServiceAccount() || cred.IsTemp() {
userid, err := parseOpenIDParentUser(cred.ParentUser)
if err == errSkipFile {
continue
}
parentUsersMap[userid] = cred
}
}
sys.store.unlock()
expiredUsers := make([]auth.Credentials, 0, len(parentUsersMap))
for userid, cred := range parentUsersMap {
u, err := globalOpenIDConfig.LookupUser(userid)
if err != nil {
logger.LogIf(GlobalContext, err)
continue
}
// Disabled parentUser purge the entries locally
if !u.Enabled {
expiredUsers = append(expiredUsers, cred)
}
}
for _, cred := range expiredUsers {
userType := regUser
if cred.IsServiceAccount() {
userType = svcUser
} else if cred.IsTemp() {
userType = stsUser
}
sys.store.deleteIAMConfig(ctx, getUserIdentityPath(cred.AccessKey, userType))
sys.store.deleteIAMConfig(ctx, getMappedPolicyPath(cred.AccessKey, userType, false))
}
sys.store.lock()
for _, cred := range expiredUsers {
delete(sys.iamUsersMap, cred.AccessKey)
delete(sys.iamUserPolicyMap, cred.AccessKey)
}
sys.store.unlock()
}
// GetUser - get user credentials
func (sys *IAMSys) GetUser(accessKey string) (cred auth.Credentials, ok bool) {
if !sys.Initialized() {
@ -1693,7 +1729,7 @@ func (sys *IAMSys) RemoveUsersFromGroup(group string, members []string) error {
// Remove the group from storage. First delete the
// mapped policy. No-mapped-policy case is ignored.
if err := sys.store.deleteMappedPolicy(context.Background(), group, regularUser, true); err != nil && err != errNoSuchPolicy {
if err := sys.store.deleteMappedPolicy(context.Background(), group, regUser, true); err != nil && err != errNoSuchPolicy {
return err
}
if err := sys.store.deleteGroupInfo(context.Background(), group); err != nil && err != errNoSuchGroup {
@ -1839,7 +1875,7 @@ func (sys *IAMSys) PolicyDBSet(name, policy string, isGroup bool) error {
return sys.policyDBSet(name, policy, stsUser, isGroup)
}
return sys.policyDBSet(name, policy, regularUser, isGroup)
return sys.policyDBSet(name, policy, regUser, isGroup)
}
// policyDBSet - sets a policy for user in the policy db. Assumes that caller
@ -1867,7 +1903,7 @@ func (sys *IAMSys) policyDBSet(name, policyName string, userType IAMUserType, is
// Add a fallback removal towards previous content that may come back
// as a ghost user due to lack of delete, this change occurred
// introduced in PR #11840
sys.store.deleteMappedPolicy(context.Background(), name, regularUser, false)
sys.store.deleteMappedPolicy(context.Background(), name, regUser, false)
}
err := sys.store.deleteMappedPolicy(context.Background(), name, userType, isGroup)
if err != nil && err != errNoSuchPolicy {

View File

@ -249,7 +249,7 @@ func (s *peerRESTServer) LoadUserHandler(w http.ResponseWriter, r *http.Request)
return
}
var userType = regularUser
var userType = regUser
if temp {
userType = stsUser
}

View File

@ -68,6 +68,16 @@ const (
ldapUsername = "ldapUsername"
)
func parseOpenIDParentUser(parentUser string) (userID string, err error) {
if strings.HasPrefix(parentUser, "jwt:") {
tokens := strings.SplitN(strings.TrimPrefix(parentUser, "jwt:"), ":", 2)
if len(tokens) == 2 {
return tokens[0], nil
}
}
return "", errSkipFile
}
// stsAPIHandlers implements and provides http handlers for AWS STS API.
type stsAPIHandlers struct{}

View File

@ -6,12 +6,15 @@ Keycloak is an open source Identity and Access Management solution aimed at mode
Configure and install keycloak server by following [Keycloak Installation Guide](https://www.keycloak.org/docs/latest/getting_started/index.html) (finish upto section 3.4)
### Configure Keycloak UI
### Configure Keycloak Realm
- Go to Clients
- Click on account
- Settings
- Enable `Implicit Flow`
- Save
- Settings
- Change `Access Type` to `confidential`.
- Save
- Click on credentials tab
- Copy the `Secret` to clipboard.
- This value is needed for `MINIO_IDENTITY_OPENID_CLIENT_SECRET` for MinIO.
- Go to Users
- Click on the user
@ -36,6 +39,42 @@ Configure and install keycloak server by following [Keycloak Installation Guide]
- Open http://localhost:8080/auth/realms/minio/.well-known/openid-configuration to verify OpenID discovery document, verify it has `authorization_endpoint` and `jwks_uri`
### Enable Keycloak Admin REST API support
Before being able to authenticate against the Admin REST API using a client_id and a client_secret you need to make sure the client is configured as it follows:
- `account` client_id is a confidential client that belongs to the realm `{realm}`
- `account` client_id is has **Service Accounts Enabled** option enabled.
- `account` client_id has a custom "Audience" mapper, in the Mappers section.
- Included Client Audience: security-admin-console
#### Adding 'admin' Role
- Go to Roles
- Add new Role `admin` with Description `${role_admin}`.
- Add this Role into compositive role named `default-roles-{realm}` - `{realm}` should be replaced with whatever realm you created from `prerequisites` section. This role is automatically trusted in the 'Service Accounts' tab.
- Check that `account` client_id has the role 'admin' assigned in the "Service Account Roles" tab.
After that, you will be able to obtain an access token for the Admin REST API using client_id and client_secret:
```
curl \
-d "client_id=<YOUR_CLIENT_ID>" \
-d "client_secret=<YOUR_CLIENT_SECRET>" \
-d "grant_type=client_credentials" \
"http://localhost:8080/auth/realms/{realm}/protocol/openid-connect/token"
```
The result will be a JSON document. To invoke the API you need to extract the value of the access_token property. You can then invoke the API by including the value in the Authorization header of requests to the API.
The following example shows how to get the details of the user with `{userid}` from `{realm}` realm:
```
curl \
-H "Authorization: Bearer eyJhbGciOiJSUz..." \
"http://localhost:8080/auth/admin/realms/{realm}/users/{userid}"
```
### Configure MinIO
```
$ export MINIO_ROOT_USER=minio

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)
}