diff --git a/cmd/iam-etcd-store.go b/cmd/iam-etcd-store.go index bd70e253c..3bfe176a6 100644 --- a/cmd/iam-etcd-store.go +++ b/cmd/iam-etcd-store.go @@ -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 { diff --git a/cmd/iam-object-store.go b/cmd/iam-object-store.go index aa8318d05..338b1b42b 100644 --- a/cmd/iam-object-store.go +++ b/cmd/iam-object-store.go @@ -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 diff --git a/cmd/iam.go b/cmd/iam.go index c83c9b4bb..310cded14 100644 --- a/cmd/iam.go +++ b/cmd/iam.go @@ -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 { diff --git a/cmd/peer-rest-server.go b/cmd/peer-rest-server.go index da4d6141a..0903374e8 100644 --- a/cmd/peer-rest-server.go +++ b/cmd/peer-rest-server.go @@ -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 } diff --git a/cmd/sts-handlers.go b/cmd/sts-handlers.go index c041844a7..976d6a95e 100644 --- a/cmd/sts-handlers.go +++ b/cmd/sts-handlers.go @@ -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{} diff --git a/docs/sts/keycloak.md b/docs/sts/keycloak.md index a1b1a4e7a..409672108 100644 --- a/docs/sts/keycloak.md +++ b/docs/sts/keycloak.md @@ -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=" \ + -d "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 diff --git a/internal/config/identity/openid/help.go b/internal/config/identity/openid/help.go index 8c220bb83..a36b22d98 100644 --- a/internal/config/identity/openid/help.go +++ b/internal/config/identity/openid/help.go @@ -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, diff --git a/internal/config/identity/openid/jwt.go b/internal/config/identity/openid/jwt.go index b467d7156..4adfba061 100644 --- a/internal/config/identity/openid/jwt.go +++ b/internal/config/identity/openid/jwt.go @@ -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 } diff --git a/internal/config/identity/openid/provider/keycloak.go b/internal/config/identity/openid/provider/keycloak.go new file mode 100644 index 000000000..49486a0d8 --- /dev/null +++ b/internal/config/identity/openid/provider/keycloak.go @@ -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 . + +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 +} diff --git a/internal/config/identity/openid/provider/provider.go b/internal/config/identity/openid/provider/provider.go new file mode 100644 index 000000000..040c65dbe --- /dev/null +++ b/internal/config/identity/openid/provider/provider.go @@ -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 . + +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) +}