diff --git a/connections/mongo/src/main/java/org/keycloak/connections/mongo/impl/types/BasicDBObjectToMapMapper.java b/connections/mongo/src/main/java/org/keycloak/connections/mongo/impl/types/BasicDBObjectToMapMapper.java index 1de0867b4a..14ada88dab 100644 --- a/connections/mongo/src/main/java/org/keycloak/connections/mongo/impl/types/BasicDBObjectToMapMapper.java +++ b/connections/mongo/src/main/java/org/keycloak/connections/mongo/impl/types/BasicDBObjectToMapMapper.java @@ -23,6 +23,11 @@ public class BasicDBObjectToMapMapper implements Mapper { String key = entry.getKey(); Object value = entry.getValue(); + // Workaround as manually inserted numbers into mongo may be treated as "Double" + if (value instanceof Double && context.getGenericTypes().get(1) == Integer.class) { + value = ((Double)value).intValue(); + } + if (key.contains(MapMapper.DOT_PLACEHOLDER)) { key = key.replaceAll(MapMapper.DOT_PLACEHOLDER, "."); } diff --git a/core/src/main/java/org/keycloak/adapters/AdapterConstants.java b/core/src/main/java/org/keycloak/adapters/AdapterConstants.java index ddc68aa653..6feb6558ef 100755 --- a/core/src/main/java/org/keycloak/adapters/AdapterConstants.java +++ b/core/src/main/java/org/keycloak/adapters/AdapterConstants.java @@ -10,8 +10,7 @@ public interface AdapterConstants { public static final String K_LOGOUT = "k_logout"; public static final String K_VERSION = "k_version"; public static final String K_PUSH_NOT_BEFORE = "k_push_not_before"; - public static final String K_GET_USER_STATS = "k_get_user_stats"; - public static final String K_GET_SESSION_STATS = "k_get_session_stats"; + public static final String K_TEST_AVAILABLE = "k_test_available"; public static final String K_QUERY_BEARER_TOKEN = "k_query_bearer_token"; // This param name is defined again in Keycloak Subsystem class diff --git a/core/src/main/java/org/keycloak/representations/adapters/action/GlobalRequestResult.java b/core/src/main/java/org/keycloak/representations/adapters/action/GlobalRequestResult.java new file mode 100644 index 0000000000..d8ab434ea5 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/adapters/action/GlobalRequestResult.java @@ -0,0 +1,60 @@ +package org.keycloak.representations.adapters.action; + +import java.util.ArrayList; +import java.util.List; + +/** + * Result of the "global" request (like push notBefore or logoutAll), which is send to all cluster nodes + * + * @author Marek Posolda + */ +public class GlobalRequestResult { + + private List successRequests; + private List failedRequests; + + public void addSuccessRequest(String reqUri) { + if (successRequests == null) { + successRequests = new ArrayList(); + } + successRequests.add(reqUri); + } + + public void addFailedRequest(String reqUri) { + if (failedRequests == null) { + failedRequests = new ArrayList(); + } + failedRequests.add(reqUri); + } + + public void addAllSuccessRequests(List reqUris) { + if (successRequests == null) { + successRequests = new ArrayList(); + } + successRequests.addAll(reqUris); + } + + public void addAllFailedRequests(List reqUris) { + if (failedRequests == null) { + failedRequests = new ArrayList(); + } + failedRequests.addAll(reqUris); + } + + public void addAll(GlobalRequestResult merged) { + if (merged.getSuccessRequests() != null && merged.getSuccessRequests().size() > 0) { + addAllSuccessRequests(merged.getSuccessRequests()); + } + if (merged.getFailedRequests() != null && merged.getFailedRequests().size() > 0) { + addAllFailedRequests(merged.getFailedRequests()); + } + } + + public List getSuccessRequests() { + return successRequests; + } + + public List getFailedRequests() { + return failedRequests; + } +} diff --git a/core/src/main/java/org/keycloak/representations/adapters/action/TestAvailabilityAction.java b/core/src/main/java/org/keycloak/representations/adapters/action/TestAvailabilityAction.java new file mode 100644 index 0000000000..13fc1d8924 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/adapters/action/TestAvailabilityAction.java @@ -0,0 +1,22 @@ +package org.keycloak.representations.adapters.action; + +/** + * @author Marek Posolda + */ +public class TestAvailabilityAction extends AdminAction { + + public static final String TEST_AVAILABILITY = "TEST_AVAILABILITY"; + + public TestAvailabilityAction() { + } + + public TestAvailabilityAction(String id, int expiration, String resource) { + super(id, expiration, resource, TEST_AVAILABILITY); + } + + @Override + public boolean validate() { + return TEST_AVAILABILITY.equals(action); + } + +} diff --git a/core/src/main/java/org/keycloak/representations/adapters/action/UserStats.java b/core/src/main/java/org/keycloak/representations/adapters/action/UserStats.java deleted file mode 100755 index 2eb9778d67..0000000000 --- a/core/src/main/java/org/keycloak/representations/adapters/action/UserStats.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.keycloak.representations.adapters.action; - -/** - * @author Bill Burke - * @version $Revision: 1 $ - */ -public class UserStats { - protected boolean loggedIn; - protected long whenLoggedIn; - - public boolean isLoggedIn() { - return loggedIn; - } - - public void setLoggedIn(boolean loggedIn) { - this.loggedIn = loggedIn; - } - - public long getWhenLoggedIn() { - return whenLoggedIn; - } - - public void setWhenLoggedIn(long whenLoggedIn) { - this.whenLoggedIn = whenLoggedIn; - } -} diff --git a/examples/demo-template/testrealm.json b/examples/demo-template/testrealm.json index 3f0e20a358..a2a3a96bfe 100755 --- a/examples/demo-template/testrealm.json +++ b/examples/demo-template/testrealm.json @@ -141,6 +141,14 @@ "/product-portal/*" ], "secret": "password" + }, + { + "name": "database-service", + "enabled": true, + "adminUrl": "/database", + "baseUrl": "/database", + "bearerOnly": true, + "secret": "password" } ], "oauthClients": [ diff --git a/forms/common-themes/src/main/resources/theme/admin/base/resources/js/app.js b/forms/common-themes/src/main/resources/theme/admin/base/resources/js/app.js index fe33ab102a..b46b13f5c3 100755 --- a/forms/common-themes/src/main/resources/theme/admin/base/resources/js/app.js +++ b/forms/common-themes/src/main/resources/theme/admin/base/resources/js/app.js @@ -440,6 +440,42 @@ module.config([ '$routeProvider', function($routeProvider) { }, controller : 'ApplicationCredentialsCtrl' }) + .when('/realms/:realm/applications/:application/clustering', { + templateUrl : 'partials/application-clustering.html', + resolve : { + realm : function(RealmLoader) { + return RealmLoader(); + }, + application : function(ApplicationLoader) { + return ApplicationLoader(); + } + }, + controller : 'ApplicationClusteringCtrl' + }) + .when('/register-node/realms/:realm/applications/:application/clustering', { + templateUrl : 'partials/application-clustering-node.html', + resolve : { + realm : function(RealmLoader) { + return RealmLoader(); + }, + application : function(ApplicationLoader) { + return ApplicationLoader(); + } + }, + controller : 'ApplicationClusteringNodeCtrl' + }) + .when('/realms/:realm/applications/:application/clustering/:node', { + templateUrl : 'partials/application-clustering-node.html', + resolve : { + realm : function(RealmLoader) { + return RealmLoader(); + }, + application : function(ApplicationLoader) { + return ApplicationLoader(); + } + }, + controller : 'ApplicationClusteringNodeCtrl' + }) .when('/realms/:realm/applications/:application/certificate', { templateUrl : 'partials/application-keys.html', resolve : { diff --git a/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/applications.js b/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/applications.js index 6d68bac310..dc4d353dc3 100755 --- a/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/applications.js +++ b/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/applications.js @@ -592,15 +592,134 @@ module.controller('ApplicationRevocationCtrl', function($scope, realm, applicati $scope.setNotBeforeNow = function() { $scope.application.notBefore = new Date().getTime()/1000; Application.update({ realm : realm.realm, application: $scope.application.id}, $scope.application, function () { - Notifications.success('Not Before cleared for application.'); + Notifications.success('Not Before set for application.'); refresh(); }); } $scope.pushRevocation = function() { - ApplicationPushRevocation.save({realm : realm.realm, application: $scope.application.id}, function () { - Notifications.success('Push sent for application.'); + ApplicationPushRevocation.save({realm : realm.realm, application: $scope.application.id}, function (globalReqResult) { + var successCount = globalReqResult.successRequests ? globalReqResult.successRequests.length : 0; + var failedCount = globalReqResult.failedRequests ? globalReqResult.failedRequests.length : 0; + + if (successCount==0 && failedCount==0) { + Notifications.warn('No push sent. No admin URI configured or no registered cluster nodes available'); + } else if (failedCount > 0) { + var msgStart = successCount>0 ? 'Successfully push notBefore to: ' + globalReqResult.successRequests + ' . ' : ''; + Notifications.error(msgStart + 'Failed to push notBefore to: ' + globalReqResult.failedRequests + '. Verify availability of failed hosts and try again'); + } else { + Notifications.success('Successfully push notBefore to: ' + globalReqResult.successRequests); + } }); } }); +module.controller('ApplicationClusteringCtrl', function($scope, application, Application, ApplicationTestNodesAvailable, realm, $location, $route, Notifications, TimeUnit) { + $scope.application = application; + $scope.realm = realm; + + var oldCopy = angular.copy($scope.application); + $scope.changed = false; + + $scope.$watch('application', function() { + if (!angular.equals($scope.application, oldCopy)) { + $scope.changed = true; + } + }, true); + + $scope.application.nodeReRegistrationTimeoutUnit = TimeUnit.autoUnit(application.nodeReRegistrationTimeout); + $scope.application.nodeReRegistrationTimeout = TimeUnit.toUnit(application.nodeReRegistrationTimeout, $scope.application.nodeReRegistrationTimeoutUnit); + $scope.$watch('application.nodeReRegistrationTimeoutUnit', function(to, from) { + $scope.application.nodeReRegistrationTimeout = TimeUnit.convert($scope.application.nodeReRegistrationTimeout, from, to); + }); + + $scope.save = function() { + var appCopy = angular.copy($scope.application); + delete appCopy['nodeReRegistrationTimeoutUnit']; + appCopy.nodeReRegistrationTimeout = TimeUnit.toSeconds($scope.application.nodeReRegistrationTimeout, $scope.application.nodeReRegistrationTimeoutUnit) + Application.update({ realm : realm.realm, application : application.id }, appCopy, function () { + $route.reload(); + Notifications.success('Your changes have been saved to the application.'); + }); + }; + + $scope.reset = function() { + $route.reload(); + }; + + $scope.testNodesAvailable = function() { + console.log('testNodesAvailable'); + ApplicationTestNodesAvailable.get({ realm : realm.realm, application : application.id }, function(globalReqResult) { + $route.reload(); + + var successCount = globalReqResult.successRequests ? globalReqResult.successRequests.length : 0; + var failedCount = globalReqResult.failedRequests ? globalReqResult.failedRequests.length : 0; + + if (successCount==0 && failedCount==0) { + Notifications.warn('No requests sent. No admin URI configured or no registered cluster nodes available'); + } else if (failedCount > 0) { + var msgStart = successCount>0 ? 'Successfully verify availability for ' + globalReqResult.successRequests + ' . ' : ''; + Notifications.error(msgStart + 'Failed to verify availability for: ' + globalReqResult.failedRequests + '. Fix or unregister failed cluster nodes and try again'); + } else { + Notifications.success('Successfully sent requests to: ' + globalReqResult.successRequests); + } + }); + }; + + if (application.registeredNodes) { + var nodeRegistrations = []; + for (node in application.registeredNodes) { + reg = { + host: node, + lastRegistration: new Date(application.registeredNodes[node] * 1000) + } + nodeRegistrations.push(reg); + } + + $scope.nodeRegistrations = nodeRegistrations; + }; +}); + +module.controller('ApplicationClusteringNodeCtrl', function($scope, application, Application, ApplicationClusterNode, realm, $location, $routeParams, Notifications) { + $scope.application = application; + $scope.realm = realm; + $scope.create = !$routeParams.node; + + $scope.save = function() { + console.log('registerNode: ' + $scope.node.host); + ApplicationClusterNode.save({ realm : realm.realm, application : application.id , node: $scope.node.host }, function() { + Notifications.success('Node ' + $scope.node.host + ' registered successfully.'); + $location.url('/realms/' + realm.realm + '/applications/' + application.id + '/clustering'); + }); + } + + $scope.unregisterNode = function() { + console.log('unregisterNode: ' + $scope.node.host); + ApplicationClusterNode.remove({ realm : realm.realm, application : application.id , node: $scope.node.host }, function() { + Notifications.success('Node ' + $scope.node.host + ' unregistered successfully.'); + $location.url('/realms/' + realm.realm + '/applications/' + application.id + '/clustering'); + }); + } + + if ($scope.create) { + $scope.node = {} + $scope.registered = false; + } else { + var lastRegTime = application.registeredNodes[$routeParams.node]; + + if (lastRegTime) { + $scope.registered = true; + $scope.node = { + host: $routeParams.node, + lastRegistration: new Date(lastRegTime * 1000) + } + + } else { + $scope.registered = false; + $scope.node = { + host: $routeParams.node + } + } + } +}); + diff --git a/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/realm.js b/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/realm.js index 86e98366ec..c8c37baf58 100755 --- a/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/realm.js +++ b/forms/common-themes/src/main/resources/theme/admin/base/resources/js/controllers/realm.js @@ -766,10 +766,18 @@ module.controller('RealmSessionStatsCtrl', function($scope, realm, stats, RealmA console.log(stats); $scope.logoutAll = function() { - RealmLogoutAll.save({realm : realm.realm}, function () { - Notifications.success('Logged out all users'); - RealmApplicationSessionStats.get({realm: realm.realm}, function(updated) { - Notifications.success('Logged out all users'); + RealmLogoutAll.save({realm : realm.realm}, function (globalReqResult) { + var successCount = globalReqResult.successRequests ? globalReqResult.successRequests.length : 0; + var failedCount = globalReqResult.failedRequests ? globalReqResult.failedRequests.length : 0; + + if (failedCount > 0) { + var msgStart = successCount>0 ? 'Successfully logout all users under: ' + globalReqResult.successRequests + ' . ' : ''; + Notifications.error(msgStart + 'Failed to logout users under: ' + globalReqResult.failedRequests + '. Verify availability of failed hosts and try again'); + } else { + Notifications.success('Successfully logout all users from the realm'); + } + + RealmApplicationSessionStats.query({realm: realm.realm}, function(updated) { $scope.stats = updated; }) }); @@ -809,13 +817,23 @@ module.controller('RealmRevocationCtrl', function($scope, Realm, RealmPushRevoca } $scope.setNotBeforeNow = function() { Realm.update({ realm: realm.realm, notBefore : new Date().getTime()/1000}, function () { - Notifications.success('Not Before cleared for realm.'); + Notifications.success('Not Before set for realm.'); reset(); }); } $scope.pushRevocation = function() { - RealmPushRevocation.save({ realm: realm.realm}, function () { - Notifications.success('Push sent for realm.'); + RealmPushRevocation.save({ realm: realm.realm}, function (globalReqResult) { + var successCount = globalReqResult.successRequests ? globalReqResult.successRequests.length : 0; + var failedCount = globalReqResult.failedRequests ? globalReqResult.failedRequests.length : 0; + + if (successCount==0 && failedCount==0) { + Notifications.warn('No push sent. No admin URI configured or no registered cluster nodes available'); + } else if (failedCount > 0) { + var msgStart = successCount>0 ? 'Successfully push notBefore to: ' + globalReqResult.successRequests + ' . ' : ''; + Notifications.error(msgStart + 'Failed to push notBefore to: ' + globalReqResult.failedRequests + '. Verify availability of failed hosts and try again'); + } else { + Notifications.success('Successfully push notBefore to all configured applications'); + } }); } diff --git a/forms/common-themes/src/main/resources/theme/admin/base/resources/js/services.js b/forms/common-themes/src/main/resources/theme/admin/base/resources/js/services.js index b87310b09d..ee4d635ccf 100755 --- a/forms/common-themes/src/main/resources/theme/admin/base/resources/js/services.js +++ b/forms/common-themes/src/main/resources/theme/admin/base/resources/js/services.js @@ -693,6 +693,20 @@ module.factory('ApplicationPushRevocation', function($resource) { }); }); +module.factory('ApplicationClusterNode', function($resource) { + return $resource(authUrl + '/admin/realms/:realm/applications-by-id/:application/nodes/:node', { + realm : '@realm', + application : "@application" + }); +}); + +module.factory('ApplicationTestNodesAvailable', function($resource) { + return $resource(authUrl + '/admin/realms/:realm/applications-by-id/:application/test-nodes-available', { + realm : '@realm', + application : "@application" + }); +}); + module.factory('ApplicationCertificate', function($resource) { return $resource(authUrl + '/admin/realms/:realm/applications-by-id/:application/certificates', { realm : '@realm', diff --git a/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/application-clustering-node.html b/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/application-clustering-node.html new file mode 100644 index 0000000000..c456ac2792 --- /dev/null +++ b/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/application-clustering-node.html @@ -0,0 +1,36 @@ +
+
+ +
+ +

{{application.name}} Clustering

+

Cluster node on host {{node.host}} not registered!

+
+
+ Configuration of cluster node +
+ +
+ +
+
+
+ +
+ +
+
+
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/application-clustering.html b/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/application-clustering.html new file mode 100644 index 0000000000..3d5c8500c5 --- /dev/null +++ b/forms/common-themes/src/main/resources/theme/admin/base/resources/partials/application-clustering.html @@ -0,0 +1,75 @@ +
+
+ +
+ +

{{application.name}} Clustering

+
+ Basic configuration +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ + +
+
+ +
+ Registered cluster nodes + + + + + + + + + + + + + + + + + + + +
+ +
Node hostLast registration
{{node.host}}{{node.lastRegistration}}
No registered cluster nodes available
+ +
+
+ +
+
\ No newline at end of file diff --git a/forms/common-themes/src/main/resources/theme/admin/base/resources/templates/kc-navigation-application.html b/forms/common-themes/src/main/resources/theme/admin/base/resources/templates/kc-navigation-application.html index d89d91c88a..d8590a2718 100755 --- a/forms/common-themes/src/main/resources/theme/admin/base/resources/templates/kc-navigation-application.html +++ b/forms/common-themes/src/main/resources/theme/admin/base/resources/templates/kc-navigation-application.html @@ -1,11 +1,12 @@ \ No newline at end of file diff --git a/integration/adapter-core/src/main/java/org/keycloak/adapters/PreAuthActionsHandler.java b/integration/adapter-core/src/main/java/org/keycloak/adapters/PreAuthActionsHandler.java index efc5463791..a55fb97251 100755 --- a/integration/adapter-core/src/main/java/org/keycloak/adapters/PreAuthActionsHandler.java +++ b/integration/adapter-core/src/main/java/org/keycloak/adapters/PreAuthActionsHandler.java @@ -7,12 +7,10 @@ import org.keycloak.jose.jws.crypto.RSAProvider; import org.keycloak.representations.adapters.action.AdminAction; import org.keycloak.representations.adapters.action.LogoutAction; import org.keycloak.representations.adapters.action.PushNotBeforeAction; +import org.keycloak.representations.adapters.action.TestAvailabilityAction; import org.keycloak.util.JsonSerialization; import org.keycloak.util.StreamUtil; -import java.util.HashMap; -import java.util.Map; - /** * @author Bill Burke * @version $Revision: 1 $ @@ -59,6 +57,9 @@ public class PreAuthActionsHandler { } else if (requestUri.endsWith(AdapterConstants.K_VERSION)) { handleVersion(); return true; + } else if (requestUri.endsWith(AdapterConstants.K_TEST_AVAILABLE)) { + handleTestAvailable(); + return true; } return false; } @@ -144,6 +145,22 @@ public class PreAuthActionsHandler { } } + protected void handleTestAvailable() { + if (log.isTraceEnabled()) { + log.trace("K_TEST_AVAILABLE sent"); + } + try { + JWSInput token = verifyAdminRequest(); + if (token == null) { + return; + } + TestAvailabilityAction action = JsonSerialization.readValue(token.getContent(), TestAvailabilityAction.class); + validateAction(action); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + protected JWSInput verifyAdminRequest() throws Exception { if (!facade.getRequest().isSecure() && deployment.getSslRequired().isRequired(facade.getRequest().getRemoteAddr())) { log.warn("SSL is required for adapter admin action"); diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java index 65b011f948..ee27557a94 100755 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java @@ -1,6 +1,5 @@ package org.keycloak.admin.client.resource; -import org.keycloak.representations.adapters.action.UserStats; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.SocialLinkRepresentation; import org.keycloak.representations.idm.UserRepresentation; @@ -17,7 +16,6 @@ import javax.ws.rs.Produces; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.util.List; -import java.util.Map; /** * @author rodrigo.sasaki@icarros.com.br @@ -51,10 +49,6 @@ public interface UserResource { @Path("reset-password-email") public void resetPasswordEmail(); - @GET - @Path("session-stats") - public Map getUserStats(); - @GET @Path("sessions") public List getUserSessions(); diff --git a/services/src/main/java/org/keycloak/services/managers/ResourceAdminManager.java b/services/src/main/java/org/keycloak/services/managers/ResourceAdminManager.java index d959883bb5..834fca8888 100755 --- a/services/src/main/java/org/keycloak/services/managers/ResourceAdminManager.java +++ b/services/src/main/java/org/keycloak/services/managers/ResourceAdminManager.java @@ -15,9 +15,10 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.representations.adapters.action.GlobalRequestResult; import org.keycloak.representations.adapters.action.LogoutAction; import org.keycloak.representations.adapters.action.PushNotBeforeAction; -import org.keycloak.representations.adapters.action.UserStats; +import org.keycloak.representations.adapters.action.TestAvailabilityAction; import org.keycloak.services.util.HttpClientBuilder; import org.keycloak.services.util.ResolveRelative; import org.keycloak.util.KeycloakUriBuilder; @@ -64,6 +65,8 @@ public class ResourceAdminManager { return StringPropertyReplacer.replaceProperties(absoluteURI); } + // For non-cluster setup, return just single configured managementUrls + // For cluster setup, return the management Urls corresponding to all registered cluster nodes private List getAllManagementUrls(URI requestUri, ApplicationModel application) { String baseMgmtUrl = getManagementUrl(requestUri, application); if (baseMgmtUrl == null) { @@ -211,49 +214,55 @@ public class ResourceAdminManager { // Methods for logout all - public void logoutAll(URI requestUri, RealmModel realm) { + public GlobalRequestResult logoutAll(URI requestUri, RealmModel realm) { ApacheHttpClient4Executor executor = createExecutor(); try { realm.setNotBefore(Time.currentTime()); List resources = realm.getApplications(); logger.debugv("logging out {0} resources ", resources.size()); + + GlobalRequestResult finalResult = new GlobalRequestResult(); for (ApplicationModel resource : resources) { - logoutApplication(requestUri, realm, resource, executor, realm.getNotBefore()); + GlobalRequestResult currentResult = logoutApplication(requestUri, realm, resource, executor, realm.getNotBefore()); + finalResult.addAll(currentResult); } + return finalResult; } finally { executor.getHttpClient().getConnectionManager().shutdown(); } } - public void logoutApplication(URI requestUri, RealmModel realm, ApplicationModel resource) { + public GlobalRequestResult logoutApplication(URI requestUri, RealmModel realm, ApplicationModel resource) { ApacheHttpClient4Executor executor = createExecutor(); try { resource.setNotBefore(Time.currentTime()); - logoutApplication(requestUri, realm, resource, executor, resource.getNotBefore()); + return logoutApplication(requestUri, realm, resource, executor, resource.getNotBefore()); } finally { executor.getHttpClient().getConnectionManager().shutdown(); } } - protected boolean logoutApplication(URI requestUri, RealmModel realm, ApplicationModel resource, ApacheHttpClient4Executor executor, int notBefore) { + protected GlobalRequestResult logoutApplication(URI requestUri, RealmModel realm, ApplicationModel resource, ApacheHttpClient4Executor executor, int notBefore) { List mgmtUrls = getAllManagementUrls(requestUri, resource); if (mgmtUrls.isEmpty()) { logger.debug("No management URL or no registered cluster nodes for the application " + resource.getName()); - return false; + return new GlobalRequestResult(); } logger.info("Send logoutApplication for URLs: " + mgmtUrls); // Propagate this to all hosts - boolean anyFailed = false; + GlobalRequestResult result = new GlobalRequestResult(); for (String mgmtUrl : mgmtUrls) { - if (!sendLogoutRequest(realm, resource, null, executor, notBefore, mgmtUrl)) { - anyFailed = true; + if (sendLogoutRequest(realm, resource, null, executor, notBefore, mgmtUrl)) { + result.addSuccessRequest(mgmtUrl); + } else { + result.addFailedRequest(mgmtUrl); } } - return !anyFailed; + return result; } protected boolean sendLogoutRequest(RealmModel realm, ApplicationModel resource, List adapterSessionIds, ApacheHttpClient4Executor client, int notBefore, String managementUrl) { @@ -263,60 +272,65 @@ public class ResourceAdminManager { ClientRequest request = client.createRequest(UriBuilder.fromUri(managementUrl).path(AdapterConstants.K_LOGOUT).build().toString()); ClientResponse response; try { - response = request.body(MediaType.TEXT_PLAIN_TYPE, token).post(UserStats.class); + response = request.body(MediaType.TEXT_PLAIN_TYPE, token).post(); } catch (Exception e) { logger.warn("Logout for application '" + resource.getName() + "' failed", e); return false; } try { - boolean success = response.getStatus() == 204; - logger.debug("logout success."); + boolean success = response.getStatus() == 204 || response.getStatus() == 200; + logger.debugf("logout success for %s: %s", managementUrl, success); return success; } finally { response.releaseConnection(); } } - public void pushRealmRevocationPolicy(URI requestUri, RealmModel realm) { + public GlobalRequestResult pushRealmRevocationPolicy(URI requestUri, RealmModel realm) { ApacheHttpClient4Executor executor = createExecutor(); try { + GlobalRequestResult finalResult = new GlobalRequestResult(); for (ApplicationModel application : realm.getApplications()) { - pushRevocationPolicy(requestUri, realm, application, realm.getNotBefore(), executor); + GlobalRequestResult currentResult = pushRevocationPolicy(requestUri, realm, application, realm.getNotBefore(), executor); + finalResult.addAll(currentResult); } + return finalResult; } finally { executor.getHttpClient().getConnectionManager().shutdown(); } } - public void pushApplicationRevocationPolicy(URI requestUri, RealmModel realm, ApplicationModel application) { + public GlobalRequestResult pushApplicationRevocationPolicy(URI requestUri, RealmModel realm, ApplicationModel application) { ApacheHttpClient4Executor executor = createExecutor(); try { - pushRevocationPolicy(requestUri, realm, application, application.getNotBefore(), executor); + return pushRevocationPolicy(requestUri, realm, application, application.getNotBefore(), executor); } finally { executor.getHttpClient().getConnectionManager().shutdown(); } } - protected boolean pushRevocationPolicy(URI requestUri, RealmModel realm, ApplicationModel resource, int notBefore, ApacheHttpClient4Executor executor) { + protected GlobalRequestResult pushRevocationPolicy(URI requestUri, RealmModel realm, ApplicationModel resource, int notBefore, ApacheHttpClient4Executor executor) { List mgmtUrls = getAllManagementUrls(requestUri, resource); if (mgmtUrls.isEmpty()) { - logger.debug("No management URL or no registered cluster nodes for the application " + resource.getName()); - return false; + logger.debugf("No management URL or no registered cluster nodes for the application %s", resource.getName()); + return new GlobalRequestResult(); } logger.info("Sending push revocation to URLS: " + mgmtUrls); // Propagate this to all hosts - boolean anyFailed= false; + GlobalRequestResult result = new GlobalRequestResult(); for (String mgmtUrl : mgmtUrls) { - if (!sendPushRevocationPolicyRequest(realm, resource, notBefore, executor, mgmtUrl)) { - anyFailed = true; + if (sendPushRevocationPolicyRequest(realm, resource, notBefore, executor, mgmtUrl)) { + result.addSuccessRequest(mgmtUrl); + } else { + result.addFailedRequest(mgmtUrl); } } - return !anyFailed; + return result; } protected boolean sendPushRevocationPolicyRequest(RealmModel realm, ApplicationModel resource, int notBefore, ApacheHttpClient4Executor client, String managementUrl) { @@ -332,11 +346,60 @@ public class ResourceAdminManager { return false; } try { - boolean success = response.getStatus() == 204; - logger.debug("pushRevocation success."); + boolean success = response.getStatus() == 204 || response.getStatus() == 200; + logger.debugf("pushRevocation success for %s: %s", managementUrl, success); return success; } finally { response.releaseConnection(); } } + + public GlobalRequestResult testNodesAvailability(URI requestUri, RealmModel realm, ApplicationModel application) { + List mgmtUrls = getAllManagementUrls(requestUri, application); + if (mgmtUrls.isEmpty()) { + logger.debug("No management URL or no registered cluster nodes for the application " + application.getName()); + return new GlobalRequestResult(); + } + + ApacheHttpClient4Executor executor = createExecutor(); + + try { + logger.info("Sending test nodes availability: " + mgmtUrls); + + // Propagate this to all hosts + GlobalRequestResult result = new GlobalRequestResult(); + for (String mgmtUrl : mgmtUrls) { + if (sendTestNodeAvailabilityRequest(realm, application, executor, mgmtUrl)) { + result.addSuccessRequest(mgmtUrl); + } else { + result.addFailedRequest(mgmtUrl); + } + } + return result; + } finally { + executor.getHttpClient().getConnectionManager().shutdown(); + } + } + + protected boolean sendTestNodeAvailabilityRequest(RealmModel realm, ApplicationModel application, ApacheHttpClient4Executor client, String managementUrl) { + TestAvailabilityAction adminAction = new TestAvailabilityAction(TokenIdGenerator.generateId(), Time.currentTime() + 30, application.getName()); + String token = new TokenManager().encodeToken(realm, adminAction); + logger.infov("testNodes availability resource: {0} url: {1}", application.getName(), managementUrl); + ClientRequest request = client.createRequest(UriBuilder.fromUri(managementUrl).path(AdapterConstants.K_TEST_AVAILABLE).build().toString()); + ClientResponse response; + try { + response = request.body(MediaType.TEXT_PLAIN_TYPE, token).post(); + } catch (Exception e) { + logger.warn("Availability test failed for uri '" + managementUrl + "'", e); + return false; + } + try { + boolean success = response.getStatus() == 204 || response.getStatus() == 200; + logger.debugf("testAvailability success for %s: %s", managementUrl, success); + return success; + } finally { + response.releaseConnection(); + } + } + } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ApplicationResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ApplicationResource.java index 0c84d11a7f..5e97698a02 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/ApplicationResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/ApplicationResource.java @@ -2,6 +2,7 @@ package org.keycloak.services.resources.admin; import org.jboss.logging.Logger; import org.jboss.resteasy.annotations.cache.NoCache; +import org.jboss.resteasy.spi.BadRequestException; import org.jboss.resteasy.spi.NotFoundException; import org.keycloak.models.ApplicationModel; import org.keycloak.models.KeycloakSession; @@ -13,6 +14,7 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.RepresentationToModel; +import org.keycloak.representations.adapters.action.GlobalRequestResult; import org.keycloak.representations.idm.ApplicationRepresentation; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.UserSessionRepresentation; @@ -22,6 +24,7 @@ import org.keycloak.services.managers.ResourceAdminManager; import org.keycloak.services.resources.KeycloakApplication; import org.keycloak.services.resources.flows.Flows; import org.keycloak.util.JsonSerialization; +import org.keycloak.util.Time; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; @@ -281,9 +284,9 @@ public class ApplicationResource { */ @Path("push-revocation") @POST - public void pushRevocation() { + public GlobalRequestResult pushRevocation() { auth.requireManage(); - new ResourceAdminManager().pushApplicationRevocationPolicy(uriInfo.getRequestUri(), realm, application); + return new ResourceAdminManager().pushApplicationRevocationPolicy(uriInfo.getRequestUri(), realm, application); } /** @@ -333,9 +336,9 @@ public class ApplicationResource { */ @Path("logout-all") @POST - public void logoutAll() { + public GlobalRequestResult logoutAll() { auth.requireManage(); - new ResourceAdminManager().logoutApplication(uriInfo.getRequestUri(), realm, application); + return new ResourceAdminManager().logoutApplication(uriInfo.getRequestUri(), realm, application); } /** @@ -354,10 +357,58 @@ public class ApplicationResource { new ResourceAdminManager().logoutUserFromApplication(uriInfo.getRequestUri(), realm, application, user, session); } + /** + * Manually register cluster node to this application - usually it's not needed to call this directly as adapter should handle + * by sending registration request to Keycloak + * + * @param formParams + */ + @Path("nodes") + @POST + @Consumes("application/json") + public void registerNode(Map formParams) { + auth.requireManage(); + String node = formParams.get("node"); + if (node == null) { + throw new BadRequestException("Node not found in params"); + } + logger.info("Register node: " + node); + application.registerNode(node, Time.currentTime()); + } + /** + * Unregister cluster node from this application + * + * @param node + */ + @Path("nodes/{node}") + @DELETE + @NoCache + public void unregisterNode(final @PathParam("node") String node) { + auth.requireManage(); + logger.info("Unregister node: " + node); + Integer time = application.getRegisteredNodes().get(node); + if (time == null) { + throw new NotFoundException("Application does not have a node " + node); + } + application.unregisterNode(node); + } + /** + * Test if registered cluster nodes are available by sending 'ping' request to all of them + * + * @return + */ + @Path("test-nodes-available") + @GET + @NoCache + public GlobalRequestResult testNodesAvailable() { + auth.requireManage(); + logger.info("Test availability of cluster nodes"); + return new ResourceAdminManager().testNodesAvailability(uriInfo.getRequestUri(), realm, application); + } } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java b/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java index 1a45769962..bc4167e3eb 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/RealmAdminResource.java @@ -19,6 +19,7 @@ import org.keycloak.models.cache.CacheUserProvider; import org.keycloak.models.utils.ModelToRepresentation; import org.keycloak.models.utils.RepresentationToModel; import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.representations.adapters.action.GlobalRequestResult; import org.keycloak.representations.idm.RealmEventsConfigRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.services.managers.LDAPConnectionTestManager; @@ -255,9 +256,9 @@ public class RealmAdminResource { */ @Path("push-revocation") @POST - public void pushRevocation() { + public GlobalRequestResult pushRevocation() { auth.requireManage(); - new ResourceAdminManager().pushRealmRevocationPolicy(uriInfo.getRequestUri(), realm); + return new ResourceAdminManager().pushRealmRevocationPolicy(uriInfo.getRequestUri(), realm); } /** @@ -267,10 +268,10 @@ public class RealmAdminResource { */ @Path("logout-all") @POST - public void logoutAll() { + public GlobalRequestResult logoutAll() { auth.requireManage(); session.sessions().removeUserSessions(realm); - new ResourceAdminManager().logoutAll(uriInfo.getRequestUri(), realm); + return new ResourceAdminManager().logoutAll(uriInfo.getRequestUri(), realm); } /**