From d8c1081578324957c4764a937e744314ee24749b Mon Sep 17 00:00:00 2001 From: mposolda Date: Sat, 18 Jul 2015 22:28:38 +0200 Subject: [PATCH 01/15] KEYCLOAK-401 KEYCLOAK-941 Service accounts initial impl. Client credentials grant support --- .../META-INF/jpa-changelog-1.4.0.xml | 6 + .../java/org/keycloak/OAuth2Constants.java | 2 + .../constants/ServiceAccountConstants.java | 20 ++ .../idm/ClientRepresentation.java | 9 + .../java/org/keycloak/events/Details.java | 6 + .../java/org/keycloak/events/EventType.java | 3 + .../theme/base/admin/resources/js/app.js | 12 + .../admin/resources/js/controllers/clients.js | 19 ++ .../partials/client-service-accounts.html | 28 +++ .../resources/templates/kc-tabs-client.html | 5 + .../java/org/keycloak/models/ClientModel.java | 3 + .../models/UserFederationManager.java | 5 + .../org/keycloak/models/UserProvider.java | 4 + .../models/entities/ClientEntity.java | 9 + .../models/utils/KeycloakModelUtils.java | 2 +- .../models/utils/ModelToRepresentation.java | 1 + .../models/utils/RepresentationToModel.java | 2 + .../models/file/FileUserProvider.java | 20 ++ .../models/file/adapter/ClientAdapter.java | 10 + .../keycloak/models/cache/ClientAdapter.java | 12 + .../cache/DefaultCacheUserProvider.java | 5 + .../models/cache/NoCacheUserProvider.java | 5 + .../models/cache/entities/CachedClient.java | 6 + .../keycloak/models/jpa/ClientAdapter.java | 10 + .../keycloak/models/jpa/JpaUserProvider.java | 34 +++ .../models/jpa/entities/ClientEntity.java | 11 + .../keycloak/adapters/ClientAdapter.java | 11 + .../keycloak/adapters/MongoUserProvider.java | 13 + .../protocol/oidc/ServiceAccountManager.java | 165 +++++++++++++ .../oidc/endpoints/TokenEndpoint.java | 21 +- .../services/managers/ClientManager.java | 56 +++++ .../resources/admin/ClientResource.java | 4 + .../org/keycloak/testsuite/AssertEvents.java | 10 + .../org/keycloak/testsuite/OAuthClient.java | 29 +++ .../testsuite/model/UserModelTest.java | 42 +++- .../testsuite/oauth/ServiceAccountTest.java | 222 ++++++++++++++++++ 36 files changed, 811 insertions(+), 11 deletions(-) create mode 100644 core/src/main/java/org/keycloak/constants/ServiceAccountConstants.java create mode 100644 forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-service-accounts.html create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/ServiceAccountManager.java create mode 100644 testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java diff --git a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.4.0.xml b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.4.0.xml index cad624168c2..103c7cea697 100755 --- a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.4.0.xml +++ b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.4.0.xml @@ -8,6 +8,12 @@ + + + + + + diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index 5aba901df5c..493748abf37 100644 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -29,6 +29,8 @@ public interface OAuth2Constants { String PASSWORD = "password"; + String CLIENT_CREDENTIALS = "client_credentials"; + } diff --git a/core/src/main/java/org/keycloak/constants/ServiceAccountConstants.java b/core/src/main/java/org/keycloak/constants/ServiceAccountConstants.java new file mode 100644 index 00000000000..928f62d915b --- /dev/null +++ b/core/src/main/java/org/keycloak/constants/ServiceAccountConstants.java @@ -0,0 +1,20 @@ +package org.keycloak.constants; + +/** + * @author Marek Posolda + */ +public interface ServiceAccountConstants { + + String CLIENT_AUTH = "client_auth"; + + String SERVICE_ACCOUNT_USER_PREFIX = "service-account-"; + String SERVICE_ACCOUNT_CLIENT_ATTRIBUTE = "serviceAccountClient"; + + String CLIENT_ID_PROTOCOL_MAPPER = "Client ID"; + String CLIENT_HOST_PROTOCOL_MAPPER = "Client Host"; + String CLIENT_ADDRESS_PROTOCOL_MAPPER = "Client IP Address"; + String CLIENT_ID = "clientId"; + String CLIENT_HOST = "clientHost"; + String CLIENT_ADDRESS = "clientAddress"; + +} diff --git a/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java index 0ffb9809227..e5ab5036400 100755 --- a/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java @@ -22,6 +22,7 @@ public class ClientRepresentation { protected Integer notBefore; protected Boolean bearerOnly; protected Boolean consentRequired; + protected Boolean serviceAccountsEnabled; protected Boolean directGrantsOnly; protected Boolean publicClient; protected Boolean frontchannelLogout; @@ -144,6 +145,14 @@ public class ClientRepresentation { this.consentRequired = consentRequired; } + public Boolean isServiceAccountsEnabled() { + return serviceAccountsEnabled; + } + + public void setServiceAccountsEnabled(Boolean serviceAccountsEnabled) { + this.serviceAccountsEnabled = serviceAccountsEnabled; + } + public Boolean isDirectGrantsOnly() { return directGrantsOnly; } diff --git a/events/api/src/main/java/org/keycloak/events/Details.java b/events/api/src/main/java/org/keycloak/events/Details.java index 38ff34078aa..23cc2f7f4b8 100755 --- a/events/api/src/main/java/org/keycloak/events/Details.java +++ b/events/api/src/main/java/org/keycloak/events/Details.java @@ -35,4 +35,10 @@ public interface Details { String IMPERSONATOR_REALM = "impersonator_realm"; String IMPERSONATOR = "impersonator"; + String CLIENT_AUTH_METHOD = "client_auth_method"; + String CLIENT_AUTH_METHOD_VALUE_CLIENT_CREDENTIALS = "client_credentials"; + String CLIENT_AUTH_METHOD_VALUE_CERTIFICATE = "client_certificate"; + String CLIENT_AUTH_METHOD_VALUE_KERBEROS_KEYTAB = "kerberos_keytab"; + String CLIENT_AUTH_METHOD_VALUE_SIGNED_JWT = "signed_jwt"; + } diff --git a/events/api/src/main/java/org/keycloak/events/EventType.java b/events/api/src/main/java/org/keycloak/events/EventType.java index be72f0623b3..d8c0d193197 100755 --- a/events/api/src/main/java/org/keycloak/events/EventType.java +++ b/events/api/src/main/java/org/keycloak/events/EventType.java @@ -15,6 +15,9 @@ public enum EventType { CODE_TO_TOKEN(true), CODE_TO_TOKEN_ERROR(true), + CLIENT_LOGIN(true), + CLIENT_LOGIN_ERROR(true), + REFRESH_TOKEN(false), REFRESH_TOKEN_ERROR(false), VALIDATE_ACCESS_TOKEN(false), diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/app.js b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/app.js index 3f8618bf76d..a500ad5e9b7 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/app.js +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/app.js @@ -762,6 +762,18 @@ module.config([ '$routeProvider', function($routeProvider) { }, controller : 'ClientInstallationCtrl' }) + .when('/realms/:realm/clients/:client/service-accounts', { + templateUrl : resourceUrl + '/partials/client-service-accounts.html', + resolve : { + realm : function(RealmLoader) { + return RealmLoader(); + }, + client : function(ClientLoader) { + return ClientLoader(); + } + }, + controller : 'ClientServiceAccountsCtrl' + }) .when('/create/client/:realm', { templateUrl : resourceUrl + '/partials/client-detail.html', resolve : { diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js index 5b3ddde8b61..13167668e23 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js @@ -1298,6 +1298,25 @@ module.controller('ClientProtocolMapperCreateCtrl', function($scope, realm, serv }); +module.controller('ClientServiceAccountsCtrl', function($scope, $http, realm, client, Notifications, Client) { + $scope.realm = realm; + $scope.client = angular.copy(client); + + $scope.serviceAccountsEnabledChanged = function() { + if (client.serviceAccountsEnabled != $scope.client.serviceAccountsEnabled) { + Client.update({ + realm : realm.realm, + client : client.id + }, $scope.client, function() { + $scope.changed = false; + client = angular.copy($scope.client); + Notifications.success("Service Account settings updated."); + }); + } + } + +}); + diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-service-accounts.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-service-accounts.html new file mode 100644 index 00000000000..1e5f0e5790a --- /dev/null +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-service-accounts.html @@ -0,0 +1,28 @@ +
+ + + +

{{client.clientId|capitalize}}

+ + + +

{{client.clientId}} Service Accounts

+

+
+
+
+ + Allows you to authenticate this client to Keycloak and retrieve access token dedicated to this client. +
+ +
+
+
+
+ +
+ + \ No newline at end of file diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-client.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-client.html index fc1d6696c6e..e350e5d090b 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-client.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-client.html @@ -25,4 +25,9 @@ Helper utility for generating various client adapter configuration formats which you can download or cut and paste to configure your clients. +
  • + Service Accounts + Allows you to authenticate this client to Keycloak and retrieve access tokens dedicated to this client. +
  • + \ No newline at end of file diff --git a/model/api/src/main/java/org/keycloak/models/ClientModel.java b/model/api/src/main/java/org/keycloak/models/ClientModel.java index 689f4cca474..0f0d9694605 100755 --- a/model/api/src/main/java/org/keycloak/models/ClientModel.java +++ b/model/api/src/main/java/org/keycloak/models/ClientModel.java @@ -103,6 +103,9 @@ public interface ClientModel extends RoleContainerModel { boolean isConsentRequired(); void setConsentRequired(boolean consentRequired); + boolean isServiceAccountsEnabled(); + void setServiceAccountsEnabled(boolean serviceAccountsEnabled); + Set getScopeMappings(); void addScopeMapping(RoleModel role); void deleteScopeMapping(RoleModel role); diff --git a/model/api/src/main/java/org/keycloak/models/UserFederationManager.java b/model/api/src/main/java/org/keycloak/models/UserFederationManager.java index 8e34a8ad333..23aaf1bd8db 100755 --- a/model/api/src/main/java/org/keycloak/models/UserFederationManager.java +++ b/model/api/src/main/java/org/keycloak/models/UserFederationManager.java @@ -304,6 +304,11 @@ public class UserFederationManager implements UserProvider { }, realm, firstResult, maxResults); } + @Override + public List searchForUserByUserAttributes(Map attributes, RealmModel realm) { + return session.userStorage().searchForUserByUserAttributes(attributes, realm); + } + @Override public Set getFederatedIdentities(UserModel user, RealmModel realm) { validateUser(realm, user); diff --git a/model/api/src/main/java/org/keycloak/models/UserProvider.java b/model/api/src/main/java/org/keycloak/models/UserProvider.java index eed008fd8a3..f48062f361e 100755 --- a/model/api/src/main/java/org/keycloak/models/UserProvider.java +++ b/model/api/src/main/java/org/keycloak/models/UserProvider.java @@ -32,6 +32,10 @@ public interface UserProvider extends Provider { List searchForUser(String search, RealmModel realm, int firstResult, int maxResults); List searchForUserByAttributes(Map attributes, RealmModel realm); List searchForUserByAttributes(Map attributes, RealmModel realm, int firstResult, int maxResults); + + // Searching by UserModel.attribute (not property) + List searchForUserByUserAttributes(Map attributes, RealmModel realm); + Set getFederatedIdentities(UserModel user, RealmModel realm); FederatedIdentityModel getFederatedIdentity(UserModel user, String socialProvider, RealmModel realm); diff --git a/model/api/src/main/java/org/keycloak/models/entities/ClientEntity.java b/model/api/src/main/java/org/keycloak/models/entities/ClientEntity.java index 109806eb47a..8e0c21bd8d1 100755 --- a/model/api/src/main/java/org/keycloak/models/entities/ClientEntity.java +++ b/model/api/src/main/java/org/keycloak/models/entities/ClientEntity.java @@ -26,6 +26,7 @@ public class ClientEntity extends AbstractIdentifiableEntity { private String baseUrl; private boolean bearerOnly; private boolean consentRequired; + private boolean serviceAccountsEnabled; private boolean directGrantsOnly; private int nodeReRegistrationTimeout; @@ -210,6 +211,14 @@ public class ClientEntity extends AbstractIdentifiableEntity { this.consentRequired = consentRequired; } + public boolean isServiceAccountsEnabled() { + return serviceAccountsEnabled; + } + + public void setServiceAccountsEnabled(boolean serviceAccountsEnabled) { + this.serviceAccountsEnabled = serviceAccountsEnabled; + } + public boolean isDirectGrantsOnly() { return directGrantsOnly; } diff --git a/model/api/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java b/model/api/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java index c98a1145092..f5261a095ab 100755 --- a/model/api/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java +++ b/model/api/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java @@ -351,6 +351,6 @@ public final class KeycloakModelUtils { } public static String toLowerCaseSafe(String str) { - return str==null ? str : str.toLowerCase(); + return str==null ? null : str.toLowerCase(); } } diff --git a/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index ebe4ed3bdfc..c2b14cdc2c3 100755 --- a/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/model/api/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -289,6 +289,7 @@ public class ModelToRepresentation { rep.setFullScopeAllowed(clientModel.isFullScopeAllowed()); rep.setBearerOnly(clientModel.isBearerOnly()); rep.setConsentRequired(clientModel.isConsentRequired()); + rep.setServiceAccountsEnabled(clientModel.isServiceAccountsEnabled()); rep.setDirectGrantsOnly(clientModel.isDirectGrantsOnly()); rep.setSurrogateAuthRequired(clientModel.isSurrogateAuthRequired()); rep.setBaseUrl(clientModel.getBaseUrl()); diff --git a/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index 0e87282d114..a38b305861a 100755 --- a/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/model/api/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -625,6 +625,7 @@ public class RepresentationToModel { if (resourceRep.getBaseUrl() != null) client.setBaseUrl(resourceRep.getBaseUrl()); if (resourceRep.isBearerOnly() != null) client.setBearerOnly(resourceRep.isBearerOnly()); if (resourceRep.isConsentRequired() != null) client.setConsentRequired(resourceRep.isConsentRequired()); + if (resourceRep.isServiceAccountsEnabled() != null) client.setServiceAccountsEnabled(resourceRep.isServiceAccountsEnabled()); if (resourceRep.isDirectGrantsOnly() != null) client.setDirectGrantsOnly(resourceRep.isDirectGrantsOnly()); if (resourceRep.isPublicClient() != null) client.setPublicClient(resourceRep.isPublicClient()); if (resourceRep.isFrontchannelLogout() != null) client.setFrontchannelLogout(resourceRep.isFrontchannelLogout()); @@ -714,6 +715,7 @@ public class RepresentationToModel { if (rep.isEnabled() != null) resource.setEnabled(rep.isEnabled()); if (rep.isBearerOnly() != null) resource.setBearerOnly(rep.isBearerOnly()); if (rep.isConsentRequired() != null) resource.setConsentRequired(rep.isConsentRequired()); + if (rep.isServiceAccountsEnabled() != null) resource.setServiceAccountsEnabled(rep.isServiceAccountsEnabled()); if (rep.isDirectGrantsOnly() != null) resource.setDirectGrantsOnly(rep.isDirectGrantsOnly()); if (rep.isPublicClient() != null) resource.setPublicClient(rep.isPublicClient()); if (rep.isFullScopeAllowed() != null) resource.setFullScopeAllowed(rep.isFullScopeAllowed()); diff --git a/model/file/src/main/java/org/keycloak/models/file/FileUserProvider.java b/model/file/src/main/java/org/keycloak/models/file/FileUserProvider.java index 94161700f06..ff152f8f7c6 100755 --- a/model/file/src/main/java/org/keycloak/models/file/FileUserProvider.java +++ b/model/file/src/main/java/org/keycloak/models/file/FileUserProvider.java @@ -38,6 +38,7 @@ import org.keycloak.models.utils.CredentialValidation; import org.keycloak.models.utils.KeycloakModelUtils; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -225,6 +226,25 @@ public class FileUserProvider implements UserProvider { return sortedSubList(found, firstResult, maxResults); } + @Override + public List searchForUserByUserAttributes(Map attributes, RealmModel realm) { + Collection users = inMemoryModel.getUsers(realm.getId()); + + for (Map.Entry entry : attributes.entrySet()) { + + List matchedUsers = new ArrayList<>(); + for (UserModel user : users) { + List vals = user.getAttribute(entry.getKey()); + if (vals.contains(entry.getValue())) { + matchedUsers.add(user); + } + } + users = matchedUsers; + } + + return (List) users; + } + @Override public Set getFederatedIdentities(UserModel userModel, RealmModel realm) { UserEntity userEntity = ((UserAdapter)getUserById(userModel.getId(), realm)).getUserEntity(); diff --git a/model/file/src/main/java/org/keycloak/models/file/adapter/ClientAdapter.java b/model/file/src/main/java/org/keycloak/models/file/adapter/ClientAdapter.java index 85ab05f6ee5..87f0ee34ee2 100755 --- a/model/file/src/main/java/org/keycloak/models/file/adapter/ClientAdapter.java +++ b/model/file/src/main/java/org/keycloak/models/file/adapter/ClientAdapter.java @@ -441,6 +441,16 @@ public class ClientAdapter implements ClientModel { entity.setConsentRequired(consentRequired); } + @Override + public boolean isServiceAccountsEnabled() { + return entity.isServiceAccountsEnabled(); + } + + @Override + public void setServiceAccountsEnabled(boolean serviceAccountsEnabled) { + entity.setServiceAccountsEnabled(serviceAccountsEnabled); + } + @Override public boolean isDirectGrantsOnly() { return entity.isDirectGrantsOnly(); diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/ClientAdapter.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/ClientAdapter.java index bc319410d1f..5c6f3827755 100755 --- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/ClientAdapter.java +++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/ClientAdapter.java @@ -412,6 +412,18 @@ public class ClientAdapter implements ClientModel { updated.setConsentRequired(consentRequired); } + @Override + public boolean isServiceAccountsEnabled() { + if (updated != null) return updated.isServiceAccountsEnabled(); + return cached.isServiceAccountsEnabled(); + } + + @Override + public void setServiceAccountsEnabled(boolean serviceAccountsEnabled) { + getDelegateForUpdate(); + updated.setServiceAccountsEnabled(serviceAccountsEnabled); + } + @Override public RoleModel getRole(String name) { if (updated != null) return updated.getRole(name); diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/DefaultCacheUserProvider.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/DefaultCacheUserProvider.java index 2f12c9fcd47..4e99e44eb78 100755 --- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/DefaultCacheUserProvider.java +++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/DefaultCacheUserProvider.java @@ -241,6 +241,11 @@ public class DefaultCacheUserProvider implements CacheUserProvider { return getDelegate().searchForUserByAttributes(attributes, realm, firstResult, maxResults); } + @Override + public List searchForUserByUserAttributes(Map attributes, RealmModel realm) { + return getDelegate().searchForUserByUserAttributes(attributes, realm); + } + @Override public Set getFederatedIdentities(UserModel user, RealmModel realm) { return getDelegate().getFederatedIdentities(user, realm); diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/NoCacheUserProvider.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/NoCacheUserProvider.java index 6137a9177fc..3abe72f7c87 100755 --- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/NoCacheUserProvider.java +++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/NoCacheUserProvider.java @@ -108,6 +108,11 @@ public class NoCacheUserProvider implements CacheUserProvider { return getDelegate().searchForUserByAttributes(attributes, realm, firstResult, maxResults); } + @Override + public List searchForUserByUserAttributes(Map attributes, RealmModel realm) { + return getDelegate().searchForUserByUserAttributes(attributes, realm); + } + @Override public Set getFederatedIdentities(UserModel user, RealmModel realm) { return getDelegate().getFederatedIdentities(user, realm); diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedClient.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedClient.java index 1133bdb8a18..911021e2fbe 100755 --- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedClient.java +++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedClient.java @@ -46,6 +46,7 @@ public class CachedClient implements Serializable { private List defaultRoles = new LinkedList(); private boolean bearerOnly; private boolean consentRequired; + private boolean serviceAccountsEnabled; private Map roles = new HashMap(); private int nodeReRegistrationTimeout; private Map registeredNodes; @@ -78,6 +79,7 @@ public class CachedClient implements Serializable { defaultRoles.addAll(model.getDefaultRoles()); bearerOnly = model.isBearerOnly(); consentRequired = model.isConsentRequired(); + serviceAccountsEnabled = model.isServiceAccountsEnabled(); for (RoleModel role : model.getRoles()) { roles.put(role.getName(), role.getId()); cache.addCachedRole(new CachedClientRole(id, role, realm)); @@ -178,6 +180,10 @@ public class CachedClient implements Serializable { return consentRequired; } + public boolean isServiceAccountsEnabled() { + return serviceAccountsEnabled; + } + public Map getRoles() { return roles; } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/ClientAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/ClientAdapter.java index 704d987ac2a..c2fab6f7767 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/ClientAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/ClientAdapter.java @@ -460,6 +460,16 @@ public class ClientAdapter implements ClientModel { entity.setConsentRequired(consentRequired); } + @Override + public boolean isServiceAccountsEnabled() { + return entity.isServiceAccountsEnabled(); + } + + @Override + public void setServiceAccountsEnabled(boolean serviceAccountsEnabled) { + entity.setServiceAccountsEnabled(serviceAccountsEnabled); + } + @Override public boolean isDirectGrantsOnly() { return entity.isDirectGrantsOnly(); diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java index 03ad18f1cd2..ae04a5f6b58 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaUserProvider.java @@ -18,8 +18,10 @@ import org.keycloak.models.utils.CredentialValidation; import org.keycloak.models.utils.KeycloakModelUtils; import javax.persistence.EntityManager; +import javax.persistence.Query; import javax.persistence.TypedQuery; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -379,6 +381,38 @@ public class JpaUserProvider implements UserProvider { return users; } + @Override + public List searchForUserByUserAttributes(Map attributes, RealmModel realm) { + StringBuilder builder = new StringBuilder("select attr.user,count(attr.user) from UserAttributeEntity attr where attr.user.realmId = :realmId"); + boolean first = true; + for (Map.Entry entry : attributes.entrySet()) { + String attrName = entry.getKey(); + if (first) { + builder.append(" and "); + first = false; + } else { + builder.append(" or "); + } + builder.append(" ( attr.name like :").append(attrName); + builder.append(" and attr.value like :").append(attrName).append("val )"); + } + builder.append(" group by attr.user having count(attr.user) = " + attributes.size()); + Query query = em.createQuery(builder.toString()); + query.setParameter("realmId", realm.getId()); + for (Map.Entry entry : attributes.entrySet()) { + query.setParameter(entry.getKey(), entry.getKey()); + query.setParameter(entry.getKey() + "val", entry.getValue()); + } + List results = query.getResultList(); + + List users = new ArrayList(); + for (Object o : results) { + UserEntity user = (UserEntity) ((Object[])o)[0]; + users.add(new UserAdapter(realm, em, user)); + } + return users; + } + private FederatedIdentityEntity findFederatedIdentity(UserModel user, String identityProvider) { TypedQuery query = em.createNamedQuery("findFederatedIdentityByUserAndProvider", FederatedIdentityEntity.class); UserEntity userEntity = em.getReference(UserEntity.class, user.getId()); diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientEntity.java index a42fa6763a4..8b57335d87e 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/ClientEntity.java @@ -95,6 +95,9 @@ public class ClientEntity { @Column(name="CONSENT_REQUIRED") private boolean consentRequired; + @Column(name="SERVICE_ACCOUNTS_ENABLED") + private boolean serviceAccountsEnabled; + @Column(name="NODE_REREG_TIMEOUT") private int nodeReRegistrationTimeout; @@ -295,6 +298,14 @@ public class ClientEntity { this.consentRequired = consentRequired; } + public boolean isServiceAccountsEnabled() { + return serviceAccountsEnabled; + } + + public void setServiceAccountsEnabled(boolean serviceAccountsEnabled) { + this.serviceAccountsEnabled = serviceAccountsEnabled; + } + public boolean isDirectGrantsOnly() { return directGrantsOnly; } diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/ClientAdapter.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/ClientAdapter.java index 0f5042090fc..40ea0d2f625 100755 --- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/ClientAdapter.java +++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/ClientAdapter.java @@ -461,6 +461,17 @@ public class ClientAdapter extends AbstractMongoAdapter imple updateMongoEntity(); } + @Override + public boolean isServiceAccountsEnabled() { + return getMongoEntity().isServiceAccountsEnabled(); + } + + @Override + public void setServiceAccountsEnabled(boolean serviceAccountsEnabled) { + getMongoEntity().setServiceAccountsEnabled(serviceAccountsEnabled); + updateMongoEntity(); + } + @Override public boolean isDirectGrantsOnly() { return getMongoEntity().isDirectGrantsOnly(); diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java index 55ac78b315f..cc720c5f94c 100755 --- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java +++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/MongoUserProvider.java @@ -214,6 +214,19 @@ public class MongoUserProvider implements UserProvider { return convertUserEntities(realm, users); } + @Override + public List searchForUserByUserAttributes(Map attributes, RealmModel realm) { + QueryBuilder queryBuilder = new QueryBuilder() + .and("realmId").is(realm.getId()); + + for (Map.Entry entry : attributes.entrySet()) { + queryBuilder.and("attributes." + entry.getKey()).is(entry.getValue()); + } + + List users = getMongoStore().loadEntities(MongoUserEntity.class, queryBuilder.get(), invocationContext); + return convertUserEntities(realm, users); + } + @Override public Set getFederatedIdentities(UserModel userModel, RealmModel realm) { UserModel user = getUserById(userModel.getId(), realm); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/ServiceAccountManager.java b/services/src/main/java/org/keycloak/protocol/oidc/ServiceAccountManager.java new file mode 100644 index 00000000000..3c8b8ad8526 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/ServiceAccountManager.java @@ -0,0 +1,165 @@ +package org.keycloak.protocol.oidc; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.spi.HttpRequest; +import org.keycloak.ClientConnection; +import org.keycloak.OAuth2Constants; +import org.keycloak.constants.ServiceAccountConstants; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientSessionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelDuplicateException; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.UserSessionProvider; +import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.services.ErrorResponseException; +import org.keycloak.services.Urls; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.ClientManager; +import org.keycloak.services.managers.RealmManager; +import org.keycloak.services.resources.Cors; + +/** + * Endpoint for authenticate clients and retrieve service accounts + * + * @author Marek Posolda + */ +public class ServiceAccountManager { + + protected static final Logger logger = Logger.getLogger(ServiceAccountManager.class); + + private TokenManager tokenManager; + private AuthenticationManager authManager; + private EventBuilder event; + private HttpRequest request; + private MultivaluedMap formParams; + + private KeycloakSession session; + + private RealmModel realm; + private HttpHeaders headers; + private UriInfo uriInfo; + private ClientConnection clientConnection; + + private ClientModel client; + private UserModel clientUser; + + public ServiceAccountManager(TokenManager tokenManager, AuthenticationManager authManager, EventBuilder event, HttpRequest request, MultivaluedMap formParams, KeycloakSession session) { + this.tokenManager = tokenManager; + this.authManager = authManager; + this.event = event; + this.request = request; + this.formParams = formParams; + this.session = session; + + this.realm = session.getContext().getRealm(); + this.headers = session.getContext().getRequestHeaders(); + this.uriInfo = session.getContext().getUri(); + this.clientConnection = session.getContext().getConnection(); + } + + public Response buildClientCredentialsGrant() { + authenticateClient(); + checkClient(); + return finishClientAuthorization(); + } + + protected void authenticateClient() { + // TODO: This should be externalized into pluggable SPI for client authentication (hopefully Authentication SPI can be reused). + // Right now, just Client Credentials Grants (as per OAuth2 specs) is supported + String authorizationHeader = headers.getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION); + client = AuthorizeClientUtil.authorizeClient(authorizationHeader, formParams, event, realm); + event.detail(Details.CLIENT_AUTH_METHOD, Details.CLIENT_AUTH_METHOD_VALUE_CLIENT_CREDENTIALS); + } + + protected void checkClient() { + if (client.isBearerOnly()) { + event.error(Errors.INVALID_CLIENT); + throw new ErrorResponseException("unauthorized_client", "Bearer-only client not allowed to retrieve service account", Response.Status.UNAUTHORIZED); + } + if (client.isPublicClient()) { + event.error(Errors.INVALID_CLIENT); + throw new ErrorResponseException("unauthorized_client", "Public client not allowed to retrieve service account", Response.Status.UNAUTHORIZED); + } + if (!client.isServiceAccountsEnabled()) { + event.error(Errors.INVALID_CLIENT); + throw new ErrorResponseException("unauthorized_client", "Client not enabled to retrieve service account", Response.Status.UNAUTHORIZED); + } + } + + protected Response finishClientAuthorization() { + event.detail(Details.RESPONSE_TYPE, ServiceAccountConstants.CLIENT_AUTH); + + Map search = new HashMap<>(); + search.put(ServiceAccountConstants.SERVICE_ACCOUNT_CLIENT_ATTRIBUTE, client.getId()); + List users = session.users().searchForUserByUserAttributes(search, realm); + + if (users.size() == 0) { + // May need to handle bootstrap here as well + logger.warnf("Service account user for client '%s' not found. Creating now", client.getClientId()); + new ClientManager(new RealmManager(session)).enableServiceAccount(client); + users = session.users().searchForUserByUserAttributes(search, realm); + clientUser = users.get(0); + } else if (users.size() == 1) { + clientUser = users.get(0); + } else { + throw new ModelDuplicateException("Multiple service account users found for client '" + client.getClientId() + "' . Check your DB"); + } + + String clientUsername = clientUser.getUsername(); + event.detail(Details.USERNAME, clientUsername); + event.user(clientUser); + + if (!clientUser.isEnabled()) { + event.error(Errors.USER_DISABLED); + throw new ErrorResponseException("invalid_request", "User '" + clientUsername + "' disabled", Response.Status.UNAUTHORIZED); + } + + String scope = formParams.getFirst(OAuth2Constants.SCOPE); + + UserSessionProvider sessions = session.sessions(); + + // TODO: Once more requirements are added, clientSession will be likely created earlier by authentication mechanism + ClientSessionModel clientSession = sessions.createClientSession(realm, client); + clientSession.setAuthMethod(OIDCLoginProtocol.LOGIN_PROTOCOL); + clientSession.setNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())); + + // TODO: Should rather obtain authMethod from client session? + UserSessionModel userSession = sessions.createUserSession(realm, clientUser, clientUsername, clientConnection.getRemoteAddr(), ServiceAccountConstants.CLIENT_AUTH, false, null, null); + event.session(userSession); + + TokenManager.attachClientSession(userSession, clientSession); + + // Notes about client details + userSession.setNote(ServiceAccountConstants.CLIENT_ID, client.getClientId()); + userSession.setNote(ServiceAccountConstants.CLIENT_HOST, clientConnection.getRemoteHost()); + userSession.setNote(ServiceAccountConstants.CLIENT_ADDRESS, clientConnection.getRemoteAddr()); + + AccessTokenResponse res = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSession) + .generateAccessToken(session, scope, client, clientUser, userSession, clientSession) + .generateRefreshToken() + .generateIDToken() + .build(); + + event.success(); + + return Cors.add(request, Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).auth().allowedOrigins(client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build(); + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index 3bff39ad867..4281c7dc8ee 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -22,6 +22,7 @@ import org.keycloak.models.UserSessionProvider; import org.keycloak.models.utils.DefaultAuthenticationFlows; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.ServiceAccountManager; import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; import org.keycloak.representations.AccessToken; @@ -53,7 +54,7 @@ public class TokenEndpoint { private ClientModel client; private enum Action { - AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD + AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS } @Context @@ -97,7 +98,11 @@ public class TokenEndpoint { checkSsl(); checkRealm(); checkGrantType(); - checkClient(); + + // client grant type will do it's own verification of client + if (!grantType.equals(OAuth2Constants.CLIENT_CREDENTIALS)) { + checkClient(); + } switch (action) { case AUTHORIZATION_CODE: @@ -106,6 +111,8 @@ public class TokenEndpoint { return buildRefreshToken(); case PASSWORD: return buildResourceOwnerPasswordCredentialsGrant(); + case CLIENT_CREDENTIALS: + return buildClientCredentialsGrant(); } throw new RuntimeException("Unknown action " + action); @@ -144,7 +151,7 @@ public class TokenEndpoint { String authorizationHeader = headers.getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION); client = AuthorizeClientUtil.authorizeClient(authorizationHeader, formParams, event, realm); - if ((client instanceof ClientModel) && ((ClientModel) client).isBearerOnly()) { + if (client.isBearerOnly()) { throw new ErrorResponseException("invalid_client", "Bearer-only not allowed", Response.Status.BAD_REQUEST); } } @@ -167,6 +174,9 @@ public class TokenEndpoint { } else if (grantType.equals(OAuth2Constants.PASSWORD)) { event.event(EventType.LOGIN); action = Action.PASSWORD; + } else if (grantType.equals(OAuth2Constants.CLIENT_CREDENTIALS)) { + event.event(EventType.CLIENT_LOGIN); + action = Action.CLIENT_CREDENTIALS; } else { throw new ErrorResponseException(Errors.INVALID_REQUEST, "Invalid " + OIDCLoginProtocol.GRANT_TYPE_PARAM, Response.Status.BAD_REQUEST); } @@ -355,4 +365,9 @@ public class TokenEndpoint { return Cors.add(request, Response.ok(res, MediaType.APPLICATION_JSON_TYPE)).auth().allowedOrigins(client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build(); } + public Response buildClientCredentialsGrant() { + ServiceAccountManager serviceAccountManager = new ServiceAccountManager(tokenManager, authManager, event, request, formParams, session); + return serviceAccountManager.buildClientCredentialsGrant(); + } + } \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/services/managers/ClientManager.java b/services/src/main/java/org/keycloak/services/managers/ClientManager.java index 40f7a32789d..a7f90795099 100755 --- a/services/src/main/java/org/keycloak/services/managers/ClientManager.java +++ b/services/src/main/java/org/keycloak/services/managers/ClientManager.java @@ -3,10 +3,15 @@ package org.keycloak.services.managers; import org.codehaus.jackson.annotate.JsonProperty; import org.codehaus.jackson.annotate.JsonPropertyOrder; import org.jboss.logging.Logger; +import org.keycloak.constants.ServiceAccountConstants; import org.keycloak.models.ClientModel; +import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionProvider; import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.mappers.UserSessionNoteMapper; import org.keycloak.representations.adapters.config.BaseRealmConfig; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.util.Time; @@ -84,6 +89,57 @@ public class ClientManager { return validatedNodes; } + public void enableServiceAccount(ClientModel client) { + client.setServiceAccountsEnabled(true); + + // Add dedicated user for this service account + RealmModel realm = client.getRealm(); + Map search = new HashMap<>(); + search.put(ServiceAccountConstants.SERVICE_ACCOUNT_CLIENT_ATTRIBUTE, client.getId()); + List serviceAccountUsers = realmManager.getSession().users().searchForUserByUserAttributes(search, realm); + if (serviceAccountUsers.size() == 0) { + String username = ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + client.getClientId(); + logger.infof("Creating service account user '%s'", username); + + UserModel user = realmManager.getSession().users().addUser(realm, username); + user.setEnabled(true); + user.setEmail(username + "@placeholder.org"); + user.setSingleAttribute(ServiceAccountConstants.SERVICE_ACCOUNT_CLIENT_ATTRIBUTE, client.getId()); + } + + // Add protocol mappers to retrieve clientId in access token + if (client.getProtocolMapperByName(OIDCLoginProtocol.LOGIN_PROTOCOL, ServiceAccountConstants.CLIENT_ID_PROTOCOL_MAPPER) == null) { + logger.debugf("Creating service account protocol mapper '%s' for client '%s'", ServiceAccountConstants.CLIENT_ID_PROTOCOL_MAPPER, client.getClientId()); + ProtocolMapperModel protocolMapper = UserSessionNoteMapper.createClaimMapper(ServiceAccountConstants.CLIENT_ID_PROTOCOL_MAPPER, + ServiceAccountConstants.CLIENT_ID, + ServiceAccountConstants.CLIENT_ID, "String", + false, "", + true, true); + client.addProtocolMapper(protocolMapper); + } + + // Add protocol mappers to retrieve hostname and IP address of client in access token + if (client.getProtocolMapperByName(OIDCLoginProtocol.LOGIN_PROTOCOL, ServiceAccountConstants.CLIENT_HOST_PROTOCOL_MAPPER) == null) { + logger.debugf("Creating service account protocol mapper '%s' for client '%s'", ServiceAccountConstants.CLIENT_HOST_PROTOCOL_MAPPER, client.getClientId()); + ProtocolMapperModel protocolMapper = UserSessionNoteMapper.createClaimMapper(ServiceAccountConstants.CLIENT_HOST_PROTOCOL_MAPPER, + ServiceAccountConstants.CLIENT_HOST, + ServiceAccountConstants.CLIENT_HOST, "String", + false, "", + true, true); + client.addProtocolMapper(protocolMapper); + } + + if (client.getProtocolMapperByName(OIDCLoginProtocol.LOGIN_PROTOCOL, ServiceAccountConstants.CLIENT_ADDRESS_PROTOCOL_MAPPER) == null) { + logger.debugf("Creating service account protocol mapper '%s' for client '%s'", ServiceAccountConstants.CLIENT_ADDRESS_PROTOCOL_MAPPER, client.getClientId()); + ProtocolMapperModel protocolMapper = UserSessionNoteMapper.createClaimMapper(ServiceAccountConstants.CLIENT_ADDRESS_PROTOCOL_MAPPER, + ServiceAccountConstants.CLIENT_ADDRESS, + ServiceAccountConstants.CLIENT_ADDRESS, "String", + false, "", + true, true); + client.addProtocolMapper(protocolMapper); + } + } + @JsonPropertyOrder({"realm", "realm-public-key", "bearer-only", "auth-server-url", "ssl-required", "resource", "public-client", "credentials", "use-resource-role-mappings"}) diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java index 3aa11904a9e..67cfb65da84 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientResource.java @@ -101,6 +101,10 @@ public class ClientResource { auth.requireManage(); try { + if (rep.isServiceAccountsEnabled() && !client.isServiceAccountsEnabled()) { + new ClientManager(new RealmManager(session)).enableServiceAccount(client);; + } + RepresentationToModel.updateClient(rep, client); adminEvent.operation(OperationType.UPDATE).resourcePath(uriInfo).representation(rep).success(); return Response.noContent().build(); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/AssertEvents.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/AssertEvents.java index 3d709779379..1441f409edd 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/AssertEvents.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/AssertEvents.java @@ -8,6 +8,7 @@ import org.junit.Assert; import org.junit.rules.TestRule; import org.junit.runners.model.Statement; import org.keycloak.Config; +import org.keycloak.constants.ServiceAccountConstants; import org.keycloak.events.admin.AdminEvent; import org.keycloak.events.Details; import org.keycloak.events.Event; @@ -130,6 +131,15 @@ public class AssertEvents implements TestRule, EventListenerProviderFactory { .session(isUUID()); } + public ExpectedEvent expectClientLogin() { + return expect(EventType.CLIENT_LOGIN) + .detail(Details.CODE_ID, isCodeId()) + .detail(Details.CLIENT_AUTH_METHOD, Details.CLIENT_AUTH_METHOD_VALUE_CLIENT_CREDENTIALS) + .detail(Details.RESPONSE_TYPE, ServiceAccountConstants.CLIENT_AUTH) + .removeDetail(Details.CODE_ID) + .session(isUUID()); + } + public ExpectedEvent expectSocialLogin() { return expect(EventType.LOGIN) .detail(Details.CODE_ID, isCodeId()) diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java index 2ad8e1be878..1bac62790e5 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/OAuthClient.java @@ -194,6 +194,31 @@ public class OAuthClient { } } + public AccessTokenResponse doClientCredentialsGrantAccessTokenRequest(String clientSecret) throws Exception { + CloseableHttpClient client = new DefaultHttpClient(); + try { + HttpPost post = new HttpPost(getServiceAccountUrl()); + + String authorization = BasicAuthHelper.createHeader(clientId, clientSecret); + post.setHeader("Authorization", authorization); + + List parameters = new LinkedList(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS)); + + UrlEncodedFormEntity formEntity; + try { + formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + post.setEntity(formEntity); + + return new AccessTokenResponse(client.execute(post)); + } finally { + closeClient(client); + } + } + public HttpResponse doLogout(String refreshToken, String clientSecret) throws IOException { CloseableHttpClient client = new DefaultHttpClient(); try { @@ -375,6 +400,10 @@ public class OAuthClient { return b.build(realm).toString(); } + public String getServiceAccountUrl() { + return getResourceOwnerPasswordCredentialGrantUrl(); + } + public String getRefreshTokenUrl() { UriBuilder b = OIDCLoginProtocolService.tokenUrl(UriBuilder.fromUri(baseUrl)); return b.build(realm).toString(); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java index afeb1d30405..d0c9d001d13 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserModelTest.java @@ -147,6 +147,7 @@ public class UserModelTest extends AbstractModelTest { public void testUserMultipleAttributes() throws Exception { RealmModel realm = realmManager.createRealm("original"); UserModel user = session.users().addUser(realm, "user"); + UserModel userNoAttrs = session.users().addUser(realm, "user-noattrs"); user.setSingleAttribute("key1", "value1"); List attrVals = new ArrayList<>(Arrays.asList( "val21", "val22" )); @@ -177,13 +178,6 @@ public class UserModelTest extends AbstractModelTest { Assert.assertEquals(allAttrVals.get("key1"), user.getAttribute("key1")); Assert.assertEquals(allAttrVals.get("key2"), user.getAttribute("key2")); - // Test searching - Map attributes = new HashMap(); - attributes.put("key2", "val22"); - List users = session.users().searchForUserByAttributes(attributes, realm); - Assert.assertEquals(1, users.size()); - Assert.assertEquals(users.get(0), user); - // Test remove and rewrite attribute user.removeAttribute("key1"); user.setSingleAttribute("key2", "val23"); @@ -198,6 +192,40 @@ public class UserModelTest extends AbstractModelTest { Assert.assertEquals("val23", attrVals.get(0)); } + @Test + public void testSearchByUserAttributes() throws Exception { + RealmModel realm = realmManager.createRealm("original"); + UserModel user1 = session.users().addUser(realm, "user1"); + UserModel user2 = session.users().addUser(realm, "user2"); + UserModel user3 = session.users().addUser(realm, "user3"); + + user1.setSingleAttribute("key1", "value1"); + user1.setSingleAttribute("key2", "value21"); + + user2.setSingleAttribute("key1", "value1"); + user2.setSingleAttribute("key2", "value22"); + + user3.setSingleAttribute("key2", "value21"); + + commit(); + + Map attributes = new HashMap(); + attributes.put("key1", "value1"); + List users = session.users().searchForUserByUserAttributes(attributes, realm); + Assert.assertEquals(2, users.size()); + Assert.assertTrue(users.contains(user1)); + Assert.assertTrue(users.contains(user2)); + + attributes.put("key2", "value21"); + users = session.users().searchForUserByUserAttributes(attributes, realm); + Assert.assertEquals(1, users.size()); + Assert.assertTrue(users.contains(user1)); + + attributes.put("key3", "value3"); + users = session.users().searchForUserByUserAttributes(attributes, realm); + Assert.assertEquals(0, users.size()); + } + public static void assertEquals(UserModel expected, UserModel actual) { Assert.assertEquals(expected.getUsername(), actual.getUsername()); Assert.assertEquals(expected.getCreatedTimestamp(), actual.getCreatedTimestamp()); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java new file mode 100644 index 00000000000..23cf541b219 --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java @@ -0,0 +1,222 @@ +package org.keycloak.testsuite.oauth; + +import org.apache.http.HttpResponse; +import org.junit.Assert; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.constants.ServiceAccountConstants; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.models.ClientModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.RefreshToken; +import org.keycloak.services.managers.ClientManager; +import org.keycloak.services.managers.RealmManager; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.OAuthClient; +import org.keycloak.testsuite.rule.KeycloakRule; +import org.keycloak.testsuite.rule.WebResource; +import org.keycloak.testsuite.rule.WebRule; +import org.openqa.selenium.WebDriver; + +import static org.junit.Assert.assertEquals; + +/** + * @author Marek Posolda + */ +public class ServiceAccountTest { + + @ClassRule + public static KeycloakRule keycloakRule = new KeycloakRule(new KeycloakRule.KeycloakSetup() { + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + ClientModel app = appRealm.addClient("service-account-cl"); + app.setSecret("secret1"); + new ClientManager(manager).enableServiceAccount(app); + + ClientModel disabledApp = appRealm.addClient("service-account-disabled"); + disabledApp.setSecret("secret1"); + + UserModel serviceAccountUser = session.users().getUserByUsername(ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "service-account-cl", appRealm); + userId = serviceAccountUser.getId(); + } + }); + + @Rule + public AssertEvents events = new AssertEvents(keycloakRule); + + @Rule + public WebRule webRule = new WebRule(this); + + @WebResource + protected WebDriver driver; + + @WebResource + protected OAuthClient oauth; + + private static String userId; + + @Test + public void clientCredentialsAuthSuccess() throws Exception { + oauth.clientId("service-account-cl"); + + OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret1"); + + assertEquals(200, response.getStatusCode()); + + AccessToken accessToken = oauth.verifyToken(response.getAccessToken()); + RefreshToken refreshToken = oauth.verifyRefreshToken(response.getRefreshToken()); + + events.expectClientLogin() + .client("service-account-cl") + .user(userId) + .session(accessToken.getSessionState()) + .detail(Details.TOKEN_ID, accessToken.getId()) + .detail(Details.REFRESH_TOKEN_ID, refreshToken.getId()) + .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "service-account-cl") + .assertEvent(); + + assertEquals(accessToken.getSessionState(), refreshToken.getSessionState()); + System.out.println("Access token other claims: " + accessToken.getOtherClaims()); + Assert.assertEquals("service-account-cl", accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_ID)); + Assert.assertTrue(accessToken.getOtherClaims().containsKey(ServiceAccountConstants.CLIENT_ADDRESS)); + Assert.assertTrue(accessToken.getOtherClaims().containsKey(ServiceAccountConstants.CLIENT_HOST)); + + OAuthClient.AccessTokenResponse refreshedResponse = oauth.doRefreshTokenRequest(response.getRefreshToken(), "secret1"); + + AccessToken refreshedAccessToken = oauth.verifyToken(refreshedResponse.getAccessToken()); + RefreshToken refreshedRefreshToken = oauth.verifyRefreshToken(refreshedResponse.getRefreshToken()); + + assertEquals(accessToken.getSessionState(), refreshedAccessToken.getSessionState()); + assertEquals(accessToken.getSessionState(), refreshedRefreshToken.getSessionState()); + + events.expectRefresh(refreshToken.getId(), refreshToken.getSessionState()).user(userId).client("service-account-cl").assertEvent(); + } + + @Test + public void clientCredentialsLogout() throws Exception { + oauth.clientId("service-account-cl"); + + OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret1"); + + assertEquals(200, response.getStatusCode()); + + AccessToken accessToken = oauth.verifyToken(response.getAccessToken()); + RefreshToken refreshToken = oauth.verifyRefreshToken(response.getRefreshToken()); + + events.expectClientLogin() + .client("service-account-cl") + .user(userId) + .session(accessToken.getSessionState()) + .detail(Details.TOKEN_ID, accessToken.getId()) + .detail(Details.REFRESH_TOKEN_ID, refreshToken.getId()) + .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "service-account-cl") + .assertEvent(); + + HttpResponse logoutResponse = oauth.doLogout(response.getRefreshToken(), "secret1"); + assertEquals(204, logoutResponse.getStatusLine().getStatusCode()); + events.expectLogout(accessToken.getSessionState()) + .client("service-account-cl") + .user(userId) + .removeDetail(Details.REDIRECT_URI) + .assertEvent(); + + response = oauth.doRefreshTokenRequest(response.getRefreshToken(), "secret1"); + assertEquals(400, response.getStatusCode()); + assertEquals("invalid_grant", response.getError()); + + events.expectRefresh(refreshToken.getId(), refreshToken.getSessionState()) + .client("service-account-cl") + .user(userId) + .removeDetail(Details.TOKEN_ID) + .removeDetail(Details.UPDATED_REFRESH_TOKEN_ID) + .error(Errors.INVALID_TOKEN).assertEvent(); + } + + @Test + public void clientCredentialsInvalidClientCredentials() throws Exception { + oauth.clientId("service-account-cl"); + + OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret2"); + + assertEquals(400, response.getStatusCode()); + + assertEquals("unauthorized_client", response.getError()); + + events.expectClientLogin() + .client("service-account-cl") + .session((String) null) + .clearDetails() + .error(Errors.INVALID_CLIENT_CREDENTIALS) + .user((String) null) + .assertEvent(); + } + + @Test + public void clientCredentialsDisabledServiceAccount() throws Exception { + oauth.clientId("service-account-disabled"); + + OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret1"); + + assertEquals(401, response.getStatusCode()); + + assertEquals("unauthorized_client", response.getError()); + + events.expectClientLogin() + .client("service-account-disabled") + .user((String) null) + .session((String) null) + .removeDetail(Details.USERNAME) + .removeDetail(Details.RESPONSE_TYPE) + .error(Errors.INVALID_CLIENT) + .assertEvent(); + } + + @Test + public void changeClientIdTest() throws Exception { + keycloakRule.update(new KeycloakRule.KeycloakSetup() { + + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + ClientModel app = appRealm.getClientByClientId("service-account-cl"); + app.setClientId("updated-client"); + } + + }); + + oauth.clientId("updated-client"); + + OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret1"); + + assertEquals(200, response.getStatusCode()); + + AccessToken accessToken = oauth.verifyToken(response.getAccessToken()); + RefreshToken refreshToken = oauth.verifyRefreshToken(response.getRefreshToken()); + Assert.assertEquals("updated-client", accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_ID)); + + // Username still same. Client ID changed + events.expectClientLogin() + .client("updated-client") + .user(userId) + .session(accessToken.getSessionState()) + .detail(Details.TOKEN_ID, accessToken.getId()) + .detail(Details.REFRESH_TOKEN_ID, refreshToken.getId()) + .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "service-account-cl") + .assertEvent(); + + // Revert change + keycloakRule.update(new KeycloakRule.KeycloakSetup() { + + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + ClientModel app = appRealm.getClientByClientId("updated-client"); + app.setClientId("service-account-cl"); + } + + }); + } + +} From 1a34feda651d85730327cb1505c9472cb1591412 Mon Sep 17 00:00:00 2001 From: mposolda Date: Wed, 22 Jul 2015 10:53:41 +0200 Subject: [PATCH 02/15] KEYCLOAK-401 Added service account demo --- examples/demo-template/README.md | 8 + examples/demo-template/pom.xml | 1 + .../demo-template/service-account/pom.xml | 60 +++++ .../example/ProductServiceAccountServlet.java | 234 ++++++++++++++++++ .../WEB-INF/jboss-deployment-structure.xml | 9 + .../src/main/webapp/WEB-INF/keycloak.json | 10 + .../src/main/webapp/WEB-INF/page.jsp | 52 ++++ .../src/main/webapp/WEB-INF/web.xml | 19 ++ .../src/main/webapp/index.html | 5 + examples/demo-template/testrealm.json | 6 + 10 files changed, 404 insertions(+) create mode 100644 examples/demo-template/service-account/pom.xml create mode 100644 examples/demo-template/service-account/src/main/java/org/keycloak/example/ProductServiceAccountServlet.java create mode 100644 examples/demo-template/service-account/src/main/webapp/WEB-INF/jboss-deployment-structure.xml create mode 100644 examples/demo-template/service-account/src/main/webapp/WEB-INF/keycloak.json create mode 100644 examples/demo-template/service-account/src/main/webapp/WEB-INF/page.jsp create mode 100644 examples/demo-template/service-account/src/main/webapp/WEB-INF/web.xml create mode 100644 examples/demo-template/service-account/src/main/webapp/index.html diff --git a/examples/demo-template/README.md b/examples/demo-template/README.md index 1a896af1915..2445e311427 100755 --- a/examples/demo-template/README.md +++ b/examples/demo-template/README.md @@ -210,6 +210,14 @@ An pure HTML5/Javascript example using Keycloak to secure it. If you are already logged in, you will not be asked for a username and password, but you will be redirected to an oauth grant page. This page asks you if you want to grant certain permissions to the third-part app. +Step 10: Service Account Example +================================ +An example for retrieve service account dedicated to the Client Application itself (not to any user). + +[http://localhost:8080/service-account-portal](http://localhost:8080/service-account-portal) + +Client authentication is done with OAuth2 Client Credentials Grant in out-of-bound request (Not Keycloak login screen displayed) + Admin Console ========================== diff --git a/examples/demo-template/pom.xml b/examples/demo-template/pom.xml index a0172c85e52..4ea8c39e81d 100755 --- a/examples/demo-template/pom.xml +++ b/examples/demo-template/pom.xml @@ -36,6 +36,7 @@ database-service third-party third-party-cdi + service-account diff --git a/examples/demo-template/service-account/pom.xml b/examples/demo-template/service-account/pom.xml new file mode 100644 index 00000000000..fde69667a00 --- /dev/null +++ b/examples/demo-template/service-account/pom.xml @@ -0,0 +1,60 @@ + + + + keycloak-examples-demo-parent + org.keycloak + 1.4.0.Final-SNAPSHOT + + + 4.0.0 + org.keycloak.example.demo + service-account-example + war + Service Account Example App + + + + + org.jboss.spec.javax.servlet + jboss-servlet-api_3.0_spec + provided + + + org.keycloak + keycloak-core + provided + + + org.keycloak + keycloak-adapter-core + provided + + + org.apache.httpcomponents + httpclient + provided + + + + + service-account-portal + + + org.jboss.as.plugins + jboss-as-maven-plugin + + false + + + + org.wildfly.plugins + wildfly-maven-plugin + + false + + + + + + \ No newline at end of file diff --git a/examples/demo-template/service-account/src/main/java/org/keycloak/example/ProductServiceAccountServlet.java b/examples/demo-template/service-account/src/main/java/org/keycloak/example/ProductServiceAccountServlet.java new file mode 100644 index 00000000000..f9dc9f165e6 --- /dev/null +++ b/examples/demo-template/service-account/src/main/java/org/keycloak/example/ProductServiceAccountServlet.java @@ -0,0 +1,234 @@ +package org.keycloak.example; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.NameValuePair; +import org.apache.http.client.HttpClient; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.message.BasicNameValuePair; +import org.keycloak.OAuth2Constants; +import org.keycloak.RSATokenVerifier; +import org.keycloak.VerificationException; +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.KeycloakDeploymentBuilder; +import org.keycloak.adapters.ServerRequest; +import org.keycloak.constants.ServiceAccountConstants; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.util.BasicAuthHelper; +import org.keycloak.util.JsonSerialization; + +/** + * @author Marek Posolda + */ +public class ProductServiceAccountServlet extends HttpServlet { + + public static final String ERROR = "error"; + public static final String TOKEN = "token"; + public static final String TOKEN_PARSED = "idTokenParsed"; + public static final String REFRESH_TOKEN = "refreshToken"; + public static final String PRODUCTS = "products"; + + @Override + public void init() throws ServletException { + InputStream config = getServletContext().getResourceAsStream("WEB-INF/keycloak.json"); + KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(config); + HttpClient client = new DefaultHttpClient(); + + getServletContext().setAttribute(KeycloakDeployment.class.getName(), deployment); + getServletContext().setAttribute(HttpClient.class.getName(), client); + } + + @Override + public void destroy() { + getHttpClient().getConnectionManager().shutdown(); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + String reqUri = req.getRequestURI(); + if (reqUri.endsWith("/login")) { + serviceAccountLogin(req); + } else if (reqUri.endsWith("/refresh")) { + refreshToken(req); + } else if (reqUri.endsWith("/logout")){ + logout(req); + } + + // Don't load products if some error happened during login,refresh or logout + if (req.getAttribute(ERROR) == null) { + loadProducts(req); + } + + req.getRequestDispatcher("/WEB-INF/page.jsp").forward(req, resp); + } + + private void serviceAccountLogin(HttpServletRequest req) { + KeycloakDeployment deployment = getKeycloakDeployment(); + HttpClient client = getHttpClient(); + + String clientId = deployment.getResourceName(); + String clientSecret = deployment.getResourceCredentials().get("secret"); + + try { + HttpPost post = new HttpPost(deployment.getTokenUrl()); + List formparams = new ArrayList(); + formparams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS)); + + String authHeader = BasicAuthHelper.createHeader(clientId, clientSecret); + post.addHeader("Authorization", authHeader); + + UrlEncodedFormEntity form = new UrlEncodedFormEntity(formparams, "UTF-8"); + post.setEntity(form); + + HttpResponse response = client.execute(post); + int status = response.getStatusLine().getStatusCode(); + HttpEntity entity = response.getEntity(); + if (status != 200) { + String json = getContent(entity); + String error = "Service account login failed. Bad status: " + status + " response: " + json; + req.setAttribute(ERROR, error); + } else if (entity == null) { + req.setAttribute(ERROR, "No entity"); + } else { + String json = getContent(entity); + AccessTokenResponse tokenResp = JsonSerialization.readValue(json, AccessTokenResponse.class); + setTokens(req, deployment, tokenResp); + } + } catch (IOException ioe) { + ioe.printStackTrace(); + req.setAttribute(ERROR, "Service account login failed. IOException occured. See server.log for details. Message is: " + ioe.getMessage()); + } catch (VerificationException vfe) { + req.setAttribute(ERROR, "Service account login failed. Failed to verify token Message is: " + vfe.getMessage()); + } + } + + private void setTokens(HttpServletRequest req, KeycloakDeployment deployment, AccessTokenResponse tokenResponse) throws IOException, VerificationException { + String token = tokenResponse.getToken(); + String refreshToken = tokenResponse.getRefreshToken(); + AccessToken tokenParsed = RSATokenVerifier.verifyToken(token, deployment.getRealmKey(), deployment.getRealmInfoUrl()); + req.getSession().setAttribute(TOKEN, token); + req.getSession().setAttribute(REFRESH_TOKEN, refreshToken); + req.getSession().setAttribute(TOKEN_PARSED, tokenParsed); + } + + private void loadProducts(HttpServletRequest req) { + HttpClient client = getHttpClient(); + String token = (String) req.getSession().getAttribute(TOKEN); + + HttpGet get = new HttpGet("http://localhost:8080/database/products"); + if (token != null) { + get.addHeader("Authorization", "Bearer " + token); + } + try { + HttpResponse response = client.execute(get); + HttpEntity entity = response.getEntity(); + int status = response.getStatusLine().getStatusCode(); + if (status != 200) { + String json = getContent(entity); + String error = "Failed retrieve products."; + + if (status == 401) { + error = error + " You need to login first with the service account."; + } else if (status == 403) { + error = error + " Maybe service account user doesn't have needed role? Assign role 'user' in Keycloak admin console to user '" + + ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + getKeycloakDeployment().getResourceName() + "' and then logout and login again."; + } + error = error + " Status: " + status + ", Response: " + json; + req.setAttribute(ERROR, error); + } else if (entity == null) { + req.setAttribute(ERROR, "No entity"); + } else { + String products = getContent(entity); + req.setAttribute(PRODUCTS, products); + } + } catch (IOException ioe) { + ioe.printStackTrace(); + req.setAttribute(ERROR, "Failed retrieve products. IOException occured. See server.log for details. Message is: " + ioe.getMessage()); + } + } + + private void refreshToken(HttpServletRequest req) { + KeycloakDeployment deployment = getKeycloakDeployment(); + String refreshToken = (String) req.getSession().getAttribute(REFRESH_TOKEN); + if (refreshToken == null) { + req.setAttribute(ERROR, "No refresh token available. Please login first"); + } else { + try { + AccessTokenResponse tokenResponse = ServerRequest.invokeRefresh(deployment, refreshToken); + setTokens(req, deployment, tokenResponse); + } catch (ServerRequest.HttpFailure hfe) { + hfe.printStackTrace(); + req.setAttribute(ERROR, "Failed refresh token. See server.log for details. Status was: " + hfe.getStatus() + ", Error is: " + hfe.getError()); + } catch (Exception ioe) { + ioe.printStackTrace(); + req.setAttribute(ERROR, "Failed refresh token. See server.log for details. Message is: " + ioe.getMessage()); + } + } + } + + private void logout(HttpServletRequest req) { + KeycloakDeployment deployment = getKeycloakDeployment(); + String refreshToken = (String) req.getSession().getAttribute(REFRESH_TOKEN); + if (refreshToken == null) { + req.setAttribute(ERROR, "No refresh token available. Please login first"); + } else { + try { + ServerRequest.invokeLogout(deployment, refreshToken); + req.getSession().removeAttribute(TOKEN); + req.getSession().removeAttribute(REFRESH_TOKEN); + req.getSession().removeAttribute(TOKEN_PARSED); + } catch (IOException ioe) { + ioe.printStackTrace(); + req.setAttribute(ERROR, "Failed refresh token. See server.log for details. Message is: " + ioe.getMessage()); + } catch (ServerRequest.HttpFailure hfe) { + hfe.printStackTrace(); + req.setAttribute(ERROR, "Failed refresh token. See server.log for details. Status was: " + hfe.getStatus() + ", Error is: " + hfe.getError()); + } + } + } + + private String getContent(HttpEntity entity) throws IOException { + if (entity == null) return null; + InputStream is = entity.getContent(); + try { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + int c; + while ((c = is.read()) != -1) { + os.write(c); + } + byte[] bytes = os.toByteArray(); + String data = new String(bytes); + return data; + } finally { + try { + is.close(); + } catch (IOException ignored) { + + } + } + + } + + private KeycloakDeployment getKeycloakDeployment() { + return (KeycloakDeployment) getServletContext().getAttribute(KeycloakDeployment.class.getName()); + } + + private HttpClient getHttpClient() { + return (HttpClient) getServletContext().getAttribute(HttpClient.class.getName()); + } +} diff --git a/examples/demo-template/service-account/src/main/webapp/WEB-INF/jboss-deployment-structure.xml b/examples/demo-template/service-account/src/main/webapp/WEB-INF/jboss-deployment-structure.xml new file mode 100644 index 00000000000..9c1bac9b368 --- /dev/null +++ b/examples/demo-template/service-account/src/main/webapp/WEB-INF/jboss-deployment-structure.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/examples/demo-template/service-account/src/main/webapp/WEB-INF/keycloak.json b/examples/demo-template/service-account/src/main/webapp/WEB-INF/keycloak.json new file mode 100644 index 00000000000..7eec22a6c3c --- /dev/null +++ b/examples/demo-template/service-account/src/main/webapp/WEB-INF/keycloak.json @@ -0,0 +1,10 @@ +{ + "realm" : "demo", + "realm-public-key" : "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "auth-server-url" : "http://localhost:8080/auth", + "ssl-required" : "external", + "resource" : "product-sa-client", + "credentials": { + "secret": "password" + } +} \ No newline at end of file diff --git a/examples/demo-template/service-account/src/main/webapp/WEB-INF/page.jsp b/examples/demo-template/service-account/src/main/webapp/WEB-INF/page.jsp new file mode 100644 index 00000000000..e151f96b49f --- /dev/null +++ b/examples/demo-template/service-account/src/main/webapp/WEB-INF/page.jsp @@ -0,0 +1,52 @@ +<%@ page language="java" contentType="text/html; charset=ISO-8859-1" + pageEncoding="ISO-8859-1" %> +<%@ page import="org.keycloak.example.ProductServiceAccountServlet" %> +<%@ page import="org.keycloak.representations.AccessToken" %> +<%@ page import="org.keycloak.constants.ServiceAccountConstants" %> +<%@ page import="org.keycloak.util.Time" %> + + + Service account portal + + +<% + AccessToken token = (AccessToken) request.getSession().getAttribute(ProductServiceAccountServlet.TOKEN_PARSED); + String products = (String) request.getAttribute(ProductServiceAccountServlet.PRODUCTS); + String appError = (String) request.getAttribute(ProductServiceAccountServlet.ERROR); +%> +

    Service account portal

    +

    Login | Refresh token | Logout

    +
    + +<% if (appError != null) { %> +

    + Error: <%= appError %> +

    +
    +<% } %> + +<% if (token != null) { %> +

    + Service account available
    + Client ID: <%= token.getOtherClaims().get(ServiceAccountConstants.CLIENT_ID) %>
    + Client hostname: <%= token.getOtherClaims().get(ServiceAccountConstants.CLIENT_HOST) %>
    + Client address: <%= token.getOtherClaims().get(ServiceAccountConstants.CLIENT_ADDRESS) %>
    + Token expiration: <%= Time.toDate(token.getExpiration()) %>
    + <% if (token.isExpired()) { %> + Access token is expired. You may need to refresh
    + <% } %> +

    +
    +<% } %> + +<% if (products != null) { %> +

    + Products retrieved successfully from REST endpoint
    + Product list: <%= products %> +

    +
    +<% } %> + + + \ No newline at end of file diff --git a/examples/demo-template/service-account/src/main/webapp/WEB-INF/web.xml b/examples/demo-template/service-account/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 00000000000..5dc7103ac03 --- /dev/null +++ b/examples/demo-template/service-account/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,19 @@ + + + + service-account-portal + + + ServiceAccountExample + org.keycloak.example.ProductServiceAccountServlet + + + + ServiceAccountExample + /app/* + + + \ No newline at end of file diff --git a/examples/demo-template/service-account/src/main/webapp/index.html b/examples/demo-template/service-account/src/main/webapp/index.html new file mode 100644 index 00000000000..e2820d1744a --- /dev/null +++ b/examples/demo-template/service-account/src/main/webapp/index.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/examples/demo-template/testrealm.json b/examples/demo-template/testrealm.json index d592010021a..a26a0582097 100755 --- a/examples/demo-template/testrealm.json +++ b/examples/demo-template/testrealm.json @@ -162,6 +162,12 @@ "publicClient": true, "directGrantsOnly": true, "consentRequired": true + }, + { + "clientId": "product-sa-client", + "enabled": true, + "secret": "password", + "serviceAccountsEnabled": true } ], "clientScopeMappings": { From 29af7a1d297a1ccc3ac3765c5e07e9d0db60c9fd Mon Sep 17 00:00:00 2001 From: Stian Thorgersen Date: Tue, 21 Jul 2015 12:31:05 +0200 Subject: [PATCH 03/15] KEYCLOAK-1427 Added delete realm button to all realm settings tabs --- .../provider/AbstractIdentityProvider.java | 5 ++++ .../admin/resources/js/controllers/realm.js | 24 ++++++++-------- .../admin/resources/partials/brute-force.html | 2 -- .../resources/partials/defense-headers.html | 2 -- .../partials/realm-cache-settings.html | 2 -- .../resources/partials/realm-create.html | 3 -- .../resources/partials/realm-detail.html | 4 --- .../admin/resources/partials/realm-keys.html | 2 -- .../partials/realm-login-settings.html | 2 -- .../admin/resources/partials/realm-smtp.html | 2 -- .../partials/realm-theme-settings.html | 2 -- .../resources/partials/realm-tokens.html | 2 -- .../admin/resources/templates/kc-menu.html | 2 +- .../resources/templates/kc-tabs-realm.html | 28 ++++++++++++------- 14 files changed, 37 insertions(+), 45 deletions(-) diff --git a/broker/core/src/main/java/org/keycloak/broker/provider/AbstractIdentityProvider.java b/broker/core/src/main/java/org/keycloak/broker/provider/AbstractIdentityProvider.java index e402e047061..6da630f557f 100755 --- a/broker/core/src/main/java/org/keycloak/broker/provider/AbstractIdentityProvider.java +++ b/broker/core/src/main/java/org/keycloak/broker/provider/AbstractIdentityProvider.java @@ -58,6 +58,11 @@ public abstract class AbstractIdentityProvider return null; } + @Override + public Response performLogin(AuthenticationRequest request) { + return null; + } + @Override public Response keycloakInitiatedBrowserLogout(UserSessionModel userSession, UriInfo uriInfo, RealmModel realm) { return null; diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js index 59a3f8518b9..154681d51f0 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js @@ -83,7 +83,7 @@ module.controller('GlobalCtrl', function($scope, $http, Auth, WhoAmI, Current, $ get impersonation() { return getAccess('impersonation'); } - } + }; $scope.$watch(function() { return $location.path(); @@ -113,6 +113,18 @@ module.controller('HomeCtrl', function(Realm, Auth, $location) { }); }); +module.controller('RealmTabCtrl', function(Dialog, $scope, Current, Realm, Notifications, $location) { + $scope.removeRealm = function() { + Dialog.confirmDelete(Current.realm.realm, 'realm', function() { + Realm.remove({ id : Current.realm.realm }, function() { + Current.realms = Realm.query(); + Notifications.success("The realm has been deleted."); + $location.url("/"); + }); + }); + }; +}); + module.controller('RealmListCtrl', function($scope, Realm, Current) { $scope.realms = Realm.query(); Current.realms = $scope.realms; @@ -286,16 +298,6 @@ module.controller('RealmDetailCtrl', function($scope, Current, Realm, realm, ser $scope.cancel = function() { window.history.back(); }; - - $scope.remove = function() { - Dialog.confirmDelete($scope.realm.realm, 'realm', function() { - Realm.remove({ id : $scope.realm.realm }, function() { - Current.realms = Realm.query(); - Notifications.success("The realm has been deleted."); - $location.url("/"); - }); - }); - }; }); function genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $location, Dialog, Notifications, url) { diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/brute-force.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/brute-force.html index a4f6db0b875..084e67d5d0c 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/brute-force.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/brute-force.html @@ -1,6 +1,4 @@
    -

    Settings

    -
    @@ -48,6 +44,10 @@ + +
    + +
    \ No newline at end of file diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-role-detail.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-role-detail.html index 1c11d57377d..c0ce7663017 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-role-detail.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-role-detail.html @@ -43,14 +43,14 @@
    - +
    - - + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/defense-headers.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/defense-headers.html index 61ef8e0633e..c6bd5b76051 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/defense-headers.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/defense-headers.html @@ -25,8 +25,8 @@
    - - + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-generic.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-generic.html index a8d72a4fffc..b74a9abc08a 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-generic.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-generic.html @@ -80,8 +80,8 @@
    - - + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-kerberos.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-kerberos.html index deda98517dd..f19b2c5cc56 100644 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-kerberos.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-kerberos.html @@ -99,8 +99,8 @@
    - - + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-ldap.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-ldap.html index 59106de3ff4..eb75db94454 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-ldap.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-ldap.html @@ -274,8 +274,8 @@
    - - + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-mapper-detail.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-mapper-detail.html index 7904d5bb4c5..b60d9040506 100644 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-mapper-detail.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/federated-mapper-detail.html @@ -48,14 +48,19 @@ -
    - - + +
    +
    + + +
    -
    - - +
    +
    + + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/identity-provider-mapper-detail.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/identity-provider-mapper-detail.html index 1b46810a0f9..2c389d7b12d 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/identity-provider-mapper-detail.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/identity-provider-mapper-detail.html @@ -47,14 +47,19 @@
    -
    - - + +
    +
    + + +
    -
    - - +
    +
    + + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/protocol-mapper-detail.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/protocol-mapper-detail.html index 27e49cd1b37..5bee1b5b6de 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/protocol-mapper-detail.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/protocol-mapper-detail.html @@ -81,8 +81,8 @@
    - - + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-create.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-create.html index 0da424e5a7e..630188d8473 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-create.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-create.html @@ -12,10 +12,10 @@ {{files[0].name}}
    -
    +
    - - + +
    @@ -41,7 +41,7 @@
    - +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc.html index fbe6a6a8880..23ad5207c00 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-oidc.html @@ -215,8 +215,8 @@
    - - + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-saml.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-saml.html index 0650b452809..7c300ae1a98 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-saml.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-saml.html @@ -188,8 +188,8 @@
    - - + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-social.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-social.html index 0bbd5b5f846..06f6c0261d1 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-social.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-identity-provider-social.html @@ -101,8 +101,8 @@
    - - + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html index f33f8ca8c6a..4f5518de23f 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html @@ -114,8 +114,8 @@
    - - + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/role-detail.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/role-detail.html index e3a52cf44d8..49d09c835e7 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/role-detail.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/role-detail.html @@ -46,8 +46,8 @@
    - - + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-consents.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-consents.html index f12f7beef8a..22db3483a90 100644 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-consents.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-consents.html @@ -35,9 +35,9 @@ - - diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-detail.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-detail.html index 49c5813f584..e9da8977959 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-detail.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-detail.html @@ -112,9 +112,9 @@
    -
    - - +
    + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-federated-identity-detail.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-federated-identity-detail.html index a9155fe0861..aa2bfbcb153 100644 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-federated-identity-detail.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-federated-identity-detail.html @@ -39,9 +39,12 @@ -
    - - + +
    +
    + + +
    diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-federated-identity-list.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-federated-identity-list.html index 36c14497478..57b8c7e5560 100644 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-federated-identity-list.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-federated-identity-list.html @@ -8,10 +8,10 @@ - + - diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-sessions.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-sessions.html index 08e520f033d..b420f476b8b 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-sessions.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-sessions.html @@ -34,7 +34,9 @@ - +
    -
    +
    @@ -29,8 +29,8 @@
    {{identity.identityProvider}} {{identity.userId}} {{identity.userName}} -
    +
    +
    logout + +