From 802a39b1ce2f6d9620f2cfa1d179111896a2332f Mon Sep 17 00:00:00 2001 From: mposolda Date: Wed, 14 Oct 2015 17:45:46 +0200 Subject: [PATCH] KEYCLOAK-904 Offline session idle timeout + admin console --- .../META-INF/jpa-changelog-1.6.0.xml | 4 + .../idm/RealmRepresentation.java | 9 ++ .../messages/admin-messages_en.properties | 3 + .../theme/base/admin/resources/js/app.js | 18 ++++ .../admin/resources/js/controllers/realm.js | 8 ++ .../admin/resources/js/controllers/users.js | 11 +++ .../theme/base/admin/resources/js/loaders.js | 10 +++ .../theme/base/admin/resources/js/services.js | 7 ++ .../partials/client-offline-sessions.html | 4 +- .../resources/partials/realm-tokens.html | 17 ++++ .../resources/partials/user-consents.html | 2 +- .../partials/user-offline-sessions.html | 35 ++++++++ .../migration/migrators/MigrateTo1_6_0.java | 2 + .../java/org/keycloak/models/Constants.java | 3 + .../java/org/keycloak/models/RealmModel.java | 4 +- .../keycloak/models/entities/RealmEntity.java | 9 ++ .../models/utils/ModelToRepresentation.java | 1 + .../models/utils/RepresentationToModel.java | 4 + .../models/file/adapter/RealmAdapter.java | 10 +++ .../models/cache/infinispan/RealmAdapter.java | 13 +++ .../models/cache/entities/CachedRealm.java | 6 ++ .../org/keycloak/models/jpa/RealmAdapter.java | 10 +++ .../models/jpa/entities/RealmEntity.java | 10 +++ .../mongo/keycloak/adapters/RealmAdapter.java | 11 +++ .../InfinispanUserSessionProvider.java | 35 +++++++- .../compat/MemUserSessionProvider.java | 23 ++++- .../initializer/InitializerState.java | 15 +++- .../initializer/OfflineUserSessionLoader.java | 2 +- .../ClientSessionsOfUserSessionMapper.java | 26 ++++-- .../mapreduce/UserSessionMapper.java | 10 ++- .../keycloak/protocol/oidc/TokenManager.java | 24 +++--- .../services/managers/ApplianceBootstrap.java | 1 + .../services/managers/UserSessionManager.java | 35 ++++++-- .../resources/admin/UsersResource.java | 38 ++++++++- .../keycloak/testsuite/model/ImportTest.java | 2 + .../model/UserSessionInitializerTest.java | 2 +- .../model/UserSessionProviderOfflineTest.java | 85 ++++++++++++++++++- .../testsuite/oauth/OfflineTokenTest.java | 31 +++++-- .../src/test/resources/model/testrealm.json | 1 + 39 files changed, 494 insertions(+), 47 deletions(-) create mode 100644 forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-offline-sessions.html diff --git a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.6.0.xml b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.6.0.xml index 9e48a6aa08..07a187a519 100644 --- a/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.6.0.xml +++ b/connections/jpa-liquibase/src/main/resources/META-INF/jpa-changelog-1.6.0.xml @@ -2,6 +2,10 @@ + + + + diff --git a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java index 1bec10b7cf..43bed913a6 100755 --- a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java @@ -14,6 +14,7 @@ public class RealmRepresentation { protected Integer accessTokenLifespan; protected Integer ssoSessionIdleTimeout; protected Integer ssoSessionMaxLifespan; + protected Integer offlineSessionIdleTimeout; protected Integer accessCodeLifespan; protected Integer accessCodeLifespanUserAction; protected Integer accessCodeLifespanLogin; @@ -199,6 +200,14 @@ public class RealmRepresentation { this.ssoSessionMaxLifespan = ssoSessionMaxLifespan; } + public Integer getOfflineSessionIdleTimeout() { + return offlineSessionIdleTimeout; + } + + public void setOfflineSessionIdleTimeout(Integer offlineSessionIdleTimeout) { + this.offlineSessionIdleTimeout = offlineSessionIdleTimeout; + } + public List getScopeMappings() { return scopeMappings; } diff --git a/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index 802645e583..36da3e89e8 100644 --- a/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/forms/common-themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -76,6 +76,8 @@ days=Days sso-session-max=SSO Session Max sso-session-idle.tooltip=Time a session is allowed to be idle before it expires. Tokens and browser sessions are invalidated when a session is expired. sso-session-max.tooltip=Max time before a session is expired. Tokens and browser sessions are invalidated when a session is expired. +offline-session-idle=Offline Session Idle +offline-session-idle.tooltip=Time an offline session is allowed to be idle before it expires. You need to use offline token to refresh at least once within this period, otherwise offline session will expire. access-token-lifespan=Access Token Lifespan access-token-lifespan.tooltip=Max time before an access token is expired. This value is recommended to be short relative to the SSO timeout. client-login-timeout=Client login timeout @@ -336,6 +338,7 @@ offline-tokens.tooltip=Total number of offline tokens for this client. show-offline-tokens=Show Offline Tokens show-offline-tokens.tooltip=Warning, this is a potentially expensive operation depending on number of offline tokens. token-issued=Token Issued +last-access=Last Access key-export=Key Export key-import=Key Import export-saml-key=Export SAML Key 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 4a1d5a3885..f1d922b31a 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 @@ -498,6 +498,24 @@ module.config([ '$routeProvider', function($routeProvider) { }, controller : 'UserConsentsCtrl' }) + .when('/realms/:realm/users/:user/offline-sessions/:client', { + templateUrl : resourceUrl + '/partials/user-offline-sessions.html', + resolve : { + realm : function(RealmLoader) { + return RealmLoader(); + }, + user : function(UserLoader) { + return UserLoader(); + }, + client : function(ClientLoader) { + return ClientLoader(); + }, + offlineSessions : function(UserOfflineSessionsLoader) { + return UserOfflineSessionsLoader(); + } + }, + controller : 'UserOfflineSessionsCtrl' + }) .when('/realms/:realm/users', { templateUrl : resourceUrl + '/partials/user-list.html', resolve : { 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 af93ac8da1..023cdf1271 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 @@ -912,6 +912,12 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $scope.realm.ssoSessionMaxLifespan = TimeUnit.convert($scope.realm.ssoSessionMaxLifespan, from, to); }); + $scope.realm.offlineSessionIdleTimeoutUnit = TimeUnit.autoUnit(realm.offlineSessionIdleTimeout); + $scope.realm.offlineSessionIdleTimeout = TimeUnit.toUnit(realm.offlineSessionIdleTimeout, $scope.realm.offlineSessionIdleTimeoutUnit); + $scope.$watch('realm.offlineSessionIdleTimeoutUnit', function(to, from) { + $scope.realm.offlineSessionIdleTimeout = TimeUnit.convert($scope.realm.offlineSessionIdleTimeout, from, to); + }); + $scope.realm.accessCodeLifespanUnit = TimeUnit.autoUnit(realm.accessCodeLifespan); $scope.realm.accessCodeLifespan = TimeUnit.toUnit(realm.accessCodeLifespan, $scope.realm.accessCodeLifespanUnit); $scope.$watch('realm.accessCodeLifespanUnit', function(to, from) { @@ -943,6 +949,7 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, var realmCopy = angular.copy($scope.realm); delete realmCopy["accessTokenLifespanUnit"]; delete realmCopy["ssoSessionMaxLifespanUnit"]; + delete realmCopy["offlineSessionIdleTimeoutUnit"]; delete realmCopy["accessCodeLifespanUnit"]; delete realmCopy["ssoSessionIdleTimeoutUnit"]; delete realmCopy["accessCodeLifespanUserActionUnit"]; @@ -951,6 +958,7 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, realmCopy.accessTokenLifespan = TimeUnit.toSeconds($scope.realm.accessTokenLifespan, $scope.realm.accessTokenLifespanUnit) realmCopy.ssoSessionIdleTimeout = TimeUnit.toSeconds($scope.realm.ssoSessionIdleTimeout, $scope.realm.ssoSessionIdleTimeoutUnit) realmCopy.ssoSessionMaxLifespan = TimeUnit.toSeconds($scope.realm.ssoSessionMaxLifespan, $scope.realm.ssoSessionMaxLifespanUnit) + realmCopy.offlineSessionIdleTimeout = TimeUnit.toSeconds($scope.realm.offlineSessionIdleTimeout, $scope.realm.offlineSessionIdleTimeoutUnit) realmCopy.accessCodeLifespan = TimeUnit.toSeconds($scope.realm.accessCodeLifespan, $scope.realm.accessCodeLifespanUnit) realmCopy.accessCodeLifespanUserAction = TimeUnit.toSeconds($scope.realm.accessCodeLifespanUserAction, $scope.realm.accessCodeLifespanUserActionUnit) realmCopy.accessCodeLifespanLogin = TimeUnit.toSeconds($scope.realm.accessCodeLifespanLogin, $scope.realm.accessCodeLifespanLoginUnit) diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js index 3746740749..4f45d63bb3 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js @@ -216,6 +216,17 @@ module.controller('UserConsentsCtrl', function($scope, realm, user, userConsents } }); +module.controller('UserOfflineSessionsCtrl', function($scope, $location, realm, user, client, offlineSessions) { + $scope.realm = realm; + $scope.user = user; + $scope.client = client; + $scope.offlineSessions = offlineSessions; + + $scope.cancel = function() { + $location.url("/realms/" + realm.realm + '/users/' + user.id + '/consents'); + }; +}); + module.controller('UserListCtrl', function($scope, realm, User, UserImpersonation, BruteForce, Notifications, $route, Dialog) { $scope.realm = realm; diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/loaders.js b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/loaders.js index 9c05940392..7706f0fda8 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/loaders.js +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/loaders.js @@ -181,6 +181,16 @@ module.factory('UserSessionsLoader', function(Loader, UserSessions, $route, $q) }); }); +module.factory('UserOfflineSessionsLoader', function(Loader, UserOfflineSessions, $route, $q) { + return Loader.query(UserOfflineSessions, function() { + return { + realm : $route.current.params.realm, + user : $route.current.params.user, + client : $route.current.params.client + } + }); +}); + module.factory('UserFederatedIdentityLoader', function(Loader, UserFederatedIdentities, $route, $q) { return Loader.query(UserFederatedIdentities, function() { return { diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/services.js b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/services.js index b92fd83648..ec0d475bf7 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/services.js +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/services.js @@ -369,6 +369,13 @@ module.factory('UserSessions', function($resource) { user : '@user' }); }); +module.factory('UserOfflineSessions', function($resource) { + return $resource(authUrl + '/admin/realms/:realm/users/:user/offline-sessions/:client', { + realm : '@realm', + user : '@user', + client : '@client' + }); +}); module.factory('UserSessionLogout', function($resource) { return $resource(authUrl + '/admin/realms/:realm/sessions/:session', { diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-offline-sessions.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-offline-sessions.html index 86f574b6eb..82f5562e06 100644 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-offline-sessions.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/client-offline-sessions.html @@ -21,7 +21,7 @@ - + @@ -49,6 +50,7 @@ +
+ @@ -31,6 +31,7 @@ {{:: 'user' | translate}} {{:: 'from-ip' | translate}} {{:: 'token-issued' | translate}}{{:: 'last-access' | translate}}
{{session.username}} {{session.ipAddress}} {{session.start | date:'medium'}}{{session.lastAccess | date:'medium'}}
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 bb5502223d..dee13f666b 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 @@ -48,6 +48,23 @@ {{:: 'sso-session-max.tooltip' | translate}} +
+ + +
+ + +
+ {{:: 'offline-session-idle.tooltip' | translate}} +
+
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 cf6db992f1..0d0a92ceb5 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 @@ -38,7 +38,7 @@ - , {{additionalGrant}} + , {{additionalGrant.key}} diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-offline-sessions.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-offline-sessions.html new file mode 100644 index 0000000000..b06f32625b --- /dev/null +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-offline-sessions.html @@ -0,0 +1,35 @@ +
+ + + + + + + + + + + + + + + + + + + +
IP AddressStartedLast Access
{{session.ipAddress}}{{session.start | date:'medium'}}{{session.lastAccess | date:'medium'}}
+ +
+
+ +
+
+
+ + \ No newline at end of file diff --git a/model/api/src/main/java/org/keycloak/migration/migrators/MigrateTo1_6_0.java b/model/api/src/main/java/org/keycloak/migration/migrators/MigrateTo1_6_0.java index ca47f3e906..730810b37b 100644 --- a/model/api/src/main/java/org/keycloak/migration/migrators/MigrateTo1_6_0.java +++ b/model/api/src/main/java/org/keycloak/migration/migrators/MigrateTo1_6_0.java @@ -47,6 +47,8 @@ public class MigrateTo1_6_0 { List realms = session.realms().getRealms(); for (RealmModel realm : realms) { + realm.setOfflineSessionIdleTimeout(Constants.DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT); + if (realm.getRole(Constants.OFFLINE_ACCESS_ROLE) == null) { for (RoleModel realmRole : realm.getRoles()) { realmRole.setScopeParamRequired(false); diff --git a/model/api/src/main/java/org/keycloak/models/Constants.java b/model/api/src/main/java/org/keycloak/models/Constants.java index 5fe3189e3f..43bdc7dc68 100755 --- a/model/api/src/main/java/org/keycloak/models/Constants.java +++ b/model/api/src/main/java/org/keycloak/models/Constants.java @@ -19,4 +19,7 @@ public interface Constants { String READ_TOKEN_ROLE = "read-token"; String[] BROKER_SERVICE_ROLES = {READ_TOKEN_ROLE}; String OFFLINE_ACCESS_ROLE = OAuth2Constants.OFFLINE_ACCESS; + + // 30 days + int DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT = 2592000; } diff --git a/model/api/src/main/java/org/keycloak/models/RealmModel.java b/model/api/src/main/java/org/keycloak/models/RealmModel.java index 7471a4c2b1..8eb13ee4a8 100755 --- a/model/api/src/main/java/org/keycloak/models/RealmModel.java +++ b/model/api/src/main/java/org/keycloak/models/RealmModel.java @@ -100,8 +100,8 @@ public interface RealmModel extends RoleContainerModel { int getSsoSessionMaxLifespan(); void setSsoSessionMaxLifespan(int seconds); -// int getOfflineSessionIdleTimeout(); -// void setOfflineSessionIdleTimeout(int seconds); + int getOfflineSessionIdleTimeout(); + void setOfflineSessionIdleTimeout(int seconds); int getAccessTokenLifespan(); diff --git a/model/api/src/main/java/org/keycloak/models/entities/RealmEntity.java b/model/api/src/main/java/org/keycloak/models/entities/RealmEntity.java index e5fe6d272f..389ec0aabc 100755 --- a/model/api/src/main/java/org/keycloak/models/entities/RealmEntity.java +++ b/model/api/src/main/java/org/keycloak/models/entities/RealmEntity.java @@ -42,6 +42,7 @@ public class RealmEntity extends AbstractIdentifiableEntity { private boolean revokeRefreshToken; private int ssoSessionIdleTimeout; private int ssoSessionMaxLifespan; + private int offlineSessionIdleTimeout; private int accessTokenLifespan; private int accessCodeLifespan; private int accessCodeLifespanUserAction; @@ -254,6 +255,14 @@ public class RealmEntity extends AbstractIdentifiableEntity { this.ssoSessionMaxLifespan = ssoSessionMaxLifespan; } + public int getOfflineSessionIdleTimeout() { + return offlineSessionIdleTimeout; + } + + public void setOfflineSessionIdleTimeout(int offlineSessionIdleTimeout) { + this.offlineSessionIdleTimeout = offlineSessionIdleTimeout; + } + public int getAccessTokenLifespan() { return accessTokenLifespan; } 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 6b4a6be2cc..c2a8a178be 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 @@ -148,6 +148,7 @@ public class ModelToRepresentation { rep.setAccessTokenLifespan(realm.getAccessTokenLifespan()); rep.setSsoSessionIdleTimeout(realm.getSsoSessionIdleTimeout()); rep.setSsoSessionMaxLifespan(realm.getSsoSessionMaxLifespan()); + rep.setOfflineSessionIdleTimeout(realm.getOfflineSessionIdleTimeout()); rep.setAccessCodeLifespan(realm.getAccessCodeLifespan()); rep.setAccessCodeLifespanUserAction(realm.getAccessCodeLifespanUserAction()); rep.setAccessCodeLifespanLogin(realm.getAccessCodeLifespanLogin()); 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 6c61e663f3..0e1e40eb04 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 @@ -1,5 +1,6 @@ package org.keycloak.models.utils; +import org.keycloak.models.Constants; import org.keycloak.util.Base64; import org.jboss.logging.Logger; import org.keycloak.enums.SslRequired; @@ -106,6 +107,8 @@ public class RepresentationToModel { else newRealm.setSsoSessionIdleTimeout(1800); if (rep.getSsoSessionMaxLifespan() != null) newRealm.setSsoSessionMaxLifespan(rep.getSsoSessionMaxLifespan()); else newRealm.setSsoSessionMaxLifespan(36000); + if (rep.getOfflineSessionIdleTimeout() != null) newRealm.setOfflineSessionIdleTimeout(rep.getOfflineSessionIdleTimeout()); + else newRealm.setOfflineSessionIdleTimeout(Constants.DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT); if (rep.getAccessCodeLifespan() != null) newRealm.setAccessCodeLifespan(rep.getAccessCodeLifespan()); else newRealm.setAccessCodeLifespan(60); @@ -535,6 +538,7 @@ public class RepresentationToModel { if (rep.getAccessTokenLifespan() != null) realm.setAccessTokenLifespan(rep.getAccessTokenLifespan()); if (rep.getSsoSessionIdleTimeout() != null) realm.setSsoSessionIdleTimeout(rep.getSsoSessionIdleTimeout()); if (rep.getSsoSessionMaxLifespan() != null) realm.setSsoSessionMaxLifespan(rep.getSsoSessionMaxLifespan()); + if (rep.getOfflineSessionIdleTimeout() != null) realm.setOfflineSessionIdleTimeout(rep.getOfflineSessionIdleTimeout()); if (rep.getRequiredCredentials() != null) { realm.updateRequiredCredentials(rep.getRequiredCredentials()); } diff --git a/model/file/src/main/java/org/keycloak/models/file/adapter/RealmAdapter.java b/model/file/src/main/java/org/keycloak/models/file/adapter/RealmAdapter.java index 26227b1ee4..381c172b47 100755 --- a/model/file/src/main/java/org/keycloak/models/file/adapter/RealmAdapter.java +++ b/model/file/src/main/java/org/keycloak/models/file/adapter/RealmAdapter.java @@ -355,6 +355,16 @@ public class RealmAdapter implements RealmModel { realm.setSsoSessionMaxLifespan(seconds); } + @Override + public int getOfflineSessionIdleTimeout() { + return realm.getOfflineSessionIdleTimeout(); + } + + @Override + public void setOfflineSessionIdleTimeout(int seconds) { + realm.setOfflineSessionIdleTimeout(seconds); + } + @Override public int getAccessTokenLifespan() { return realm.getAccessTokenLifespan(); diff --git a/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java b/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java index 51d445c890..e9b92a63eb 100755 --- a/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java +++ b/model/invalidation-cache/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java @@ -275,6 +275,19 @@ public class RealmAdapter implements RealmModel { updated.setSsoSessionMaxLifespan(seconds); } + @Override + public int getOfflineSessionIdleTimeout() { + if (updated != null) return updated.getOfflineSessionIdleTimeout(); + return cached.getOfflineSessionIdleTimeout(); + } + + + @Override + public void setOfflineSessionIdleTimeout(int seconds) { + getDelegateForUpdate(); + updated.setOfflineSessionIdleTimeout(seconds); + } + @Override public int getAccessTokenLifespan() { if (updated != null) return updated.getAccessTokenLifespan(); diff --git a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedRealm.java b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedRealm.java index 5193588d44..3aa7d383f6 100755 --- a/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedRealm.java +++ b/model/invalidation-cache/model-adapters/src/main/java/org/keycloak/models/cache/entities/CachedRealm.java @@ -58,6 +58,7 @@ public class CachedRealm implements Serializable { private boolean revokeRefreshToken; private int ssoSessionIdleTimeout; private int ssoSessionMaxLifespan; + private int offlineSessionIdleTimeout; private int accessTokenLifespan; private int accessCodeLifespan; private int accessCodeLifespanUserAction; @@ -140,6 +141,7 @@ public class CachedRealm implements Serializable { revokeRefreshToken = model.isRevokeRefreshToken(); ssoSessionIdleTimeout = model.getSsoSessionIdleTimeout(); ssoSessionMaxLifespan = model.getSsoSessionMaxLifespan(); + offlineSessionIdleTimeout = model.getOfflineSessionIdleTimeout(); accessTokenLifespan = model.getAccessTokenLifespan(); accessCodeLifespan = model.getAccessCodeLifespan(); accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction(); @@ -327,6 +329,10 @@ public class CachedRealm implements Serializable { return ssoSessionMaxLifespan; } + public int getOfflineSessionIdleTimeout() { + return offlineSessionIdleTimeout; + } + public int getAccessTokenLifespan() { return accessTokenLifespan; } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java index 2c8e2ad9fc..9290013ddc 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java @@ -377,6 +377,16 @@ public class RealmAdapter implements RealmModel { realm.setSsoSessionMaxLifespan(seconds); } + @Override + public int getOfflineSessionIdleTimeout() { + return realm.getOfflineSessionIdleTimeout(); + } + + @Override + public void setOfflineSessionIdleTimeout(int seconds) { + realm.setOfflineSessionIdleTimeout(seconds); + } + @Override public int getAccessCodeLifespan() { return realm.getAccessCodeLifespan(); diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmEntity.java index 27c4824f94..bf5b339577 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmEntity.java @@ -82,6 +82,8 @@ public class RealmEntity { private int ssoSessionIdleTimeout; @Column(name="SSO_MAX_LIFESPAN") private int ssoSessionMaxLifespan; + @Column(name="OFFLINE_SESSION_IDLE_TIMEOUT") + private int offlineSessionIdleTimeout; @Column(name="ACCESS_TOKEN_LIFESPAN") protected int accessTokenLifespan; @Column(name="ACCESS_CODE_LIFESPAN") @@ -314,6 +316,14 @@ public class RealmEntity { this.ssoSessionMaxLifespan = ssoSessionMaxLifespan; } + public int getOfflineSessionIdleTimeout() { + return offlineSessionIdleTimeout; + } + + public void setOfflineSessionIdleTimeout(int offlineSessionIdleTimeout) { + this.offlineSessionIdleTimeout = offlineSessionIdleTimeout; + } + public int getAccessTokenLifespan() { return accessTokenLifespan; } diff --git a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java index 05ae8bcd4c..b03c463792 100755 --- a/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java +++ b/model/mongo/src/main/java/org/keycloak/models/mongo/keycloak/adapters/RealmAdapter.java @@ -344,6 +344,17 @@ public class RealmAdapter extends AbstractMongoAdapter impleme updateRealm(); } + @Override + public int getOfflineSessionIdleTimeout() { + return realm.getOfflineSessionIdleTimeout(); + } + + @Override + public void setOfflineSessionIdleTimeout(int seconds) { + realm.setOfflineSessionIdleTimeout(seconds); + updateRealm(); + } + @Override public int getAccessTokenLifespan() { return realm.getAccessTokenLifespan(); diff --git a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java index dbfecb4357..34cc4bc3c9 100755 --- a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java +++ b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java @@ -13,6 +13,7 @@ import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionProvider; import org.keycloak.models.UsernameLoginFailureModel; +import org.keycloak.models.session.UserSessionPersisterProvider; import org.keycloak.models.sessions.infinispan.entities.ClientSessionEntity; import org.keycloak.models.sessions.infinispan.entities.LoginFailureEntity; import org.keycloak.models.sessions.infinispan.entities.LoginFailureKey; @@ -302,8 +303,11 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { @Override public void removeExpiredUserSessions(RealmModel realm) { + UserSessionPersisterProvider persister = session.getProvider(UserSessionPersisterProvider.class); + int expired = Time.currentTime() - realm.getSsoSessionMaxLifespan(); int expiredRefresh = Time.currentTime() - realm.getSsoSessionIdleTimeout(); + int expiredOffline = Time.currentTime() - realm.getOfflineSessionIdleTimeout(); int expiredDettachedClientSession = Time.currentTime() - RealmInfoUtil.getDettachedClientSessionLifespan(realm); Map map = new MapReduceTask(sessionCache) @@ -323,6 +327,29 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { for (String id : map.keySet()) { tx.remove(sessionCache, id); } + + // Remove expired offline user sessions + map = new MapReduceTask(offlineSessionCache) + .mappedWith(UserSessionMapper.create(realm.getId()).expired(null, expiredOffline).emitKey()) + .reducedWith(new FirstResultReducer()) + .execute(); + + for (String id : map.keySet()) { + tx.remove(offlineSessionCache, id); + // propagate to persister + persister.removeUserSession(id, true); + } + + // Remove offline client sessions of expired offline user sessions + map = new MapReduceTask(offlineSessionCache) + .mappedWith(new ClientSessionsOfUserSessionMapper(realm.getId(), new HashSet<>(map.keySet())).emitKey()) + .reducedWith(new FirstResultReducer()) + .execute(); + + for (String id : map.keySet()) { + tx.remove(offlineSessionCache, id); + } + } @Override @@ -477,6 +504,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { tx.remove(cache, userSessionId); + // TODO: We can retrieve it from userSessionEntity directly Map map = new MapReduceTask(cache) .mappedWith(ClientSessionMapper.create(realm.getId()).userSession(userSessionId).emitKey()) .reducedWith(new FirstResultReducer()) @@ -534,14 +562,17 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { entity.setBrokerSessionId(userSession.getBrokerSessionId()); entity.setBrokerUserId(userSession.getBrokerUserId()); entity.setIpAddress(userSession.getIpAddress()); - entity.setLastSessionRefresh(userSession.getLastSessionRefresh()); entity.setLoginUsername(userSession.getLoginUsername()); entity.setNotes(userSession.getNotes()); entity.setRememberMe(userSession.isRememberMe()); - entity.setStarted(userSession.getStarted()); entity.setState(userSession.getState()); entity.setUser(userSession.getUser().getId()); + // started and lastSessionRefresh set to current time + int currentTime = Time.currentTime(); + entity.setStarted(currentTime); + entity.setLastSessionRefresh(currentTime); + tx.put(offlineSessionCache, userSession.getId(), entity); return wrap(userSession.getRealm(), entity, true); } diff --git a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/MemUserSessionProvider.java b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/MemUserSessionProvider.java index 0e5f2b9784..6cbb1eb82c 100755 --- a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/MemUserSessionProvider.java +++ b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/MemUserSessionProvider.java @@ -10,6 +10,7 @@ import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionProvider; import org.keycloak.models.UsernameLoginFailureModel; +import org.keycloak.models.session.UserSessionPersisterProvider; import org.keycloak.models.sessions.infinispan.compat.entities.ClientSessionEntity; import org.keycloak.models.sessions.infinispan.compat.entities.UserSessionEntity; import org.keycloak.models.sessions.infinispan.compat.entities.UsernameLoginFailureEntity; @@ -297,6 +298,8 @@ public class MemUserSessionProvider implements UserSessionProvider { @Override public void removeExpiredUserSessions(RealmModel realm) { + UserSessionPersisterProvider persister = session.getProvider(UserSessionPersisterProvider.class); + Iterator itr = userSessions.values().iterator(); while (itr.hasNext()) { UserSessionEntity s = itr.next(); @@ -314,6 +317,19 @@ public class MemUserSessionProvider implements UserSessionProvider { citr.remove(); } } + + // Remove expired offline sessions + itr = offlineUserSessions.values().iterator(); + while (itr.hasNext()) { + UserSessionEntity s = itr.next(); + if (s.getRealm().equals(realm.getId()) && (s.getLastSessionRefresh() < Time.currentTime() - realm.getOfflineSessionIdleTimeout())) { + itr.remove(); + remove(s, true); + + // propagate to persister + persister.removeUserSession(s.getId(), true); + } + } } @Override @@ -415,16 +431,19 @@ public class MemUserSessionProvider implements UserSessionProvider { entity.setBrokerSessionId(userSession.getBrokerSessionId()); entity.setBrokerUserId(userSession.getBrokerUserId()); entity.setIpAddress(userSession.getIpAddress()); - entity.setLastSessionRefresh(userSession.getLastSessionRefresh()); entity.setLoginUsername(userSession.getLoginUsername()); if (userSession.getNotes() != null) { entity.getNotes().putAll(userSession.getNotes()); } entity.setRememberMe(userSession.isRememberMe()); - entity.setStarted(userSession.getStarted()); entity.setState(userSession.getState()); entity.setUser(userSession.getUser().getId()); + // started and lastSessionRefresh set to current time + int currentTime = Time.currentTime(); + entity.setStarted(currentTime); + entity.setLastSessionRefresh(currentTime); + offlineUserSessions.put(userSession.getId(), entity); return new UserSessionAdapter(session, this, userSession.getRealm(), entity); } diff --git a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/initializer/InitializerState.java b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/initializer/InitializerState.java index eda7370b42..ccc6fd6a69 100644 --- a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/initializer/InitializerState.java +++ b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/initializer/InitializerState.java @@ -15,6 +15,7 @@ public class InitializerState extends SessionEntity { private int sessionsCount; private List segments = new ArrayList<>(); + private int lowestUnfinishedSegment = 0; public void init(int sessionsCount, int sessionsPerSegment) { @@ -31,18 +32,21 @@ public class InitializerState extends SessionEntity { for (int i=0 ; i getUnfinishedSegments(int segmentCount) { List result = new ArrayList<>(); - boolean remaining = true; - int next=0; + int next = lowestUnfinishedSegment; + boolean remaining = lowestUnfinishedSegment != -1; + while (remaining && result.size() < segmentCount) { next = getNextUnfinishedSegmentFromIndex(next); if (next == -1) { @@ -58,6 +62,11 @@ public class InitializerState extends SessionEntity { public void markSegmentFinished(int index) { segments.set(index, true); + updateLowestUnfinishedSegment(); + } + + private void updateLowestUnfinishedSegment() { + this.lowestUnfinishedSegment = getNextUnfinishedSegmentFromIndex(lowestUnfinishedSegment); } private int getNextUnfinishedSegmentFromIndex(int index) { diff --git a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/initializer/OfflineUserSessionLoader.java b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/initializer/OfflineUserSessionLoader.java index e53236997a..20ec696c07 100644 --- a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/initializer/OfflineUserSessionLoader.java +++ b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/initializer/OfflineUserSessionLoader.java @@ -29,7 +29,7 @@ public class OfflineUserSessionLoader implements SessionLoader { for (UserSessionModel persistentSession : sessions) { - // Update and persist lastSessionRefresh time + // Update and persist lastSessionRefresh time TODO: Do bulk DB update instead? persistentSession.setLastSessionRefresh(currentTime); persister.updateUserSession(persistentSession, true); diff --git a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/mapreduce/ClientSessionsOfUserSessionMapper.java b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/mapreduce/ClientSessionsOfUserSessionMapper.java index 8300944b37..2f538aef76 100644 --- a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/mapreduce/ClientSessionsOfUserSessionMapper.java +++ b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/mapreduce/ClientSessionsOfUserSessionMapper.java @@ -13,18 +13,29 @@ import org.keycloak.models.sessions.infinispan.entities.SessionEntity; * * @author Marek Posolda */ -public class ClientSessionsOfUserSessionMapper implements Mapper, Serializable { +public class ClientSessionsOfUserSessionMapper implements Mapper, Serializable { private String realm; private Collection userSessions; + private EmitValue emit = EmitValue.ENTITY; + + private enum EmitValue { + KEY, ENTITY + } + public ClientSessionsOfUserSessionMapper(String realm, Collection userSessions) { this.realm = realm; this.userSessions = userSessions; } + public ClientSessionsOfUserSessionMapper emitKey() { + emit = EmitValue.KEY; + return this; + } + @Override - public void map(String key, SessionEntity e, Collector collector) { + public void map(String key, SessionEntity e, Collector collector) { if (!realm.equals(e.getRealm())) { return; } @@ -35,9 +46,14 @@ public class ClientSessionsOfUserSessionMapper implements Mapper expiredRefresh) { + return; + } + switch (emit) { case KEY: collector.emit(key, key); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 0888dab878..8882da4f8b 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -98,9 +98,17 @@ public class TokenManager { ClientSessionModel clientSession = null; if (TokenUtil.TOKEN_TYPE_OFFLINE.equals(oldToken.getType())) { - clientSession = new UserSessionManager(session).findOfflineClientSession(realm, oldToken.getClientSession(), oldToken.getSessionState()); + UserSessionManager sessionManager = new UserSessionManager(session); + clientSession = sessionManager.findOfflineClientSession(realm, oldToken.getClientSession(), oldToken.getSessionState()); if (clientSession != null) { userSession = clientSession.getUserSession(); + + // Revoke timeouted offline userSession + if (userSession.getLastSessionRefresh() < Time.currentTime() - realm.getOfflineSessionIdleTimeout()) { + sessionManager.revokeOfflineUserSession(userSession); + userSession = null; + clientSession = null; + } } } else { // Find userSession regularly for online tokens @@ -172,16 +180,12 @@ public class TokenManager { validation.userSession.setLastSessionRefresh(currentTime); - AccessTokenResponseBuilder responseBuilder = responseBuilder(realm, authorizedClient, event, session, validation.userSession, validation.clientSession) + AccessTokenResponse res = responseBuilder(realm, authorizedClient, event, session, validation.userSession, validation.clientSession) .accessToken(validation.newToken) - .generateIDToken(); + .generateIDToken() + .generateRefreshToken() + .build(); - // Don't generate refresh token again if refresh was triggered with offline token - if (!refreshToken.getType().equals(TokenUtil.TOKEN_TYPE_OFFLINE)) { - responseBuilder.generateRefreshToken(); - } - - AccessTokenResponse res = responseBuilder.build(); return new RefreshResult(res, TokenUtil.TOKEN_TYPE_OFFLINE.equals(refreshToken.getType())); } @@ -507,7 +511,7 @@ public class TokenManager { refreshToken = new RefreshToken(accessToken); refreshToken.type(TokenUtil.TOKEN_TYPE_OFFLINE); - sessionManager.persistOfflineSession(clientSession, userSession); + sessionManager.createOrUpdateOfflineSession(clientSession, userSession); } else { refreshToken = new RefreshToken(accessToken); refreshToken.expiration(Time.currentTime() + realm.getSsoSessionIdleTimeout()); diff --git a/services/src/main/java/org/keycloak/services/managers/ApplianceBootstrap.java b/services/src/main/java/org/keycloak/services/managers/ApplianceBootstrap.java index 95b4243667..d3569650a2 100755 --- a/services/src/main/java/org/keycloak/services/managers/ApplianceBootstrap.java +++ b/services/src/main/java/org/keycloak/services/managers/ApplianceBootstrap.java @@ -52,6 +52,7 @@ public class ApplianceBootstrap { realm.setSsoSessionIdleTimeout(1800); realm.setAccessTokenLifespan(60); realm.setSsoSessionMaxLifespan(36000); + realm.setOfflineSessionIdleTimeout(Constants.DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT); realm.setAccessCodeLifespan(60); realm.setAccessCodeLifespanUserAction(300); realm.setAccessCodeLifespanLogin(1800); diff --git a/services/src/main/java/org/keycloak/services/managers/UserSessionManager.java b/services/src/main/java/org/keycloak/services/managers/UserSessionManager.java index 5759d2031c..2f18d24e95 100644 --- a/services/src/main/java/org/keycloak/services/managers/UserSessionManager.java +++ b/services/src/main/java/org/keycloak/services/managers/UserSessionManager.java @@ -1,6 +1,7 @@ package org.keycloak.services.managers; import java.util.HashSet; +import java.util.LinkedList; import java.util.List; import java.util.Set; @@ -15,6 +16,7 @@ import org.keycloak.models.RoleModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.models.session.UserSessionPersisterProvider; +import org.keycloak.util.Time; /** * @@ -32,17 +34,23 @@ public class UserSessionManager { this.persister = session.getProvider(UserSessionPersisterProvider.class); } - public void persistOfflineSession(ClientSessionModel clientSession, UserSessionModel userSession) { + public void createOrUpdateOfflineSession(ClientSessionModel clientSession, UserSessionModel userSession) { UserModel user = userSession.getUser(); - // Verify if we already have UserSession with this ID. If yes, don't create another one + // Create and persist offline userSession if we don't have one UserSessionModel offlineUserSession = kcSession.sessions().getOfflineUserSession(clientSession.getRealm(), userSession.getId()); if (offlineUserSession == null) { offlineUserSession = createOfflineUserSession(user, userSession); + } else { + // update lastSessionRefresh but don't need to persist + offlineUserSession.setLastSessionRefresh(Time.currentTime()); } - // Create clientSession and save to DB. - createOfflineClientSession(user, clientSession, offlineUserSession); + // Create and persist clientSession + ClientSessionModel offlineClientSession = kcSession.sessions().getOfflineClientSession(clientSession.getRealm(), clientSession.getId()); + if (offlineClientSession == null) { + createOfflineClientSession(user, clientSession, offlineUserSession); + } } // userSessionId is provided from offline token. It's used just to verify if it match the ID from clientSession representation @@ -69,6 +77,15 @@ public class UserSessionManager { return clients; } + public List findOfflineSessions(RealmModel realm, ClientModel client, UserModel user) { + List clientSessions = kcSession.sessions().getOfflineClientSessions(realm, user); + List userSessions = new LinkedList<>(); + for (ClientSessionModel clientSession : clientSessions) { + userSessions.add(clientSession.getUserSession()); + } + return userSessions; + } + public boolean revokeOfflineToken(UserModel user, ClientModel client) { RealmModel realm = client.getRealm(); @@ -91,6 +108,14 @@ public class UserSessionManager { return anyRemoved; } + public void revokeOfflineUserSession(UserSessionModel userSession) { + if (logger.isTraceEnabled()) { + logger.tracef("Removing offline user session '%s' for user '%s' ", userSession.getId(), userSession.getLoginUsername()); + } + kcSession.sessions().removeOfflineUserSession(userSession.getRealm(), userSession.getId()); + persister.removeUserSession(userSession.getId(), true); + } + public boolean isOfflineTokenAllowed(ClientSessionModel clientSession) { RoleModel offlineAccessRole = clientSession.getRealm().getRole(Constants.OFFLINE_ACCESS_ROLE); if (offlineAccessRole == null) { @@ -107,7 +132,7 @@ public class UserSessionManager { } UserSessionModel offlineUserSession = kcSession.sessions().createOfflineUserSession(userSession); - persister.createUserSession(userSession, true); + persister.createUserSession(offlineUserSession, true); return offlineUserSession; } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java index 22ebbb5c11..4c8796d431 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java @@ -349,6 +349,35 @@ public class UsersResource { return reps; } + /** + * Get offline sessions associated with the user and client + * + * @param id User id + * @return + */ + @Path("{id}/offline-sessions/{clientId}") + @GET + @NoCache + @Produces(MediaType.APPLICATION_JSON) + public List getSessions(final @PathParam("id") String id, final @PathParam("clientId") String clientId) { + auth.requireView(); + UserModel user = session.users().getUserById(id, realm); + if (user == null) { + throw new NotFoundException("User not found"); + } + ClientModel client = realm.getClientById(clientId); + if (client == null) { + throw new NotFoundException("Client not found"); + } + List sessions = new UserSessionManager(session).findOfflineSessions(realm, client, user); + List reps = new ArrayList(); + for (UserSessionModel session : sessions) { + UserSessionRepresentation rep = ModelToRepresentation.toRepresentation(session); + reps.add(rep); + } + return reps; + } + /** * Get social logins associated with the user * @@ -469,7 +498,14 @@ public class UsersResource { currentRep.put("grantedRealmRoles", (rep==null ? Collections.emptyList() : rep.getGrantedRealmRoles())); currentRep.put("grantedClientRoles", (rep==null ? Collections.emptyMap() : rep.getGrantedClientRoles())); - List additionalGrants = hasOfflineToken ? Arrays.asList("Offline Token") : Collections.emptyList(); + List> additionalGrants = new LinkedList<>(); + if (hasOfflineToken) { + Map offlineTokens = new HashMap<>(); + offlineTokens.put("client", client.getId()); + // TODO: translate + offlineTokens.put("key", "Offline Token"); + additionalGrants.add(offlineTokens); + } currentRep.put("additionalGrants", additionalGrants); result.add(currentRep); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ImportTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ImportTest.java index b13bcdc5cd..5d7eae7ef7 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ImportTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/ImportTest.java @@ -76,6 +76,7 @@ public class ImportTest extends AbstractModelTest { // Moved to static method, so it's possible to test this from other places too (for example export-import tests) public static void assertDataImportedInRealm(KeycloakSession session, RealmModel realm) { Assert.assertTrue(realm.isVerifyEmail()); + Assert.assertEquals(3600000, realm.getOfflineSessionIdleTimeout()); List creds = realm.getRequiredCredentials(); Assert.assertEquals(1, creds.size()); @@ -361,6 +362,7 @@ public class ImportTest extends AbstractModelTest { RealmModel realm =manager.importRealm(rep); Assert.assertEquals(600, realm.getAccessCodeLifespanUserAction()); + Assert.assertEquals(Constants.DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT, realm.getOfflineSessionIdleTimeout()); verifyRequiredCredentials(realm.getRequiredCredentials(), "password"); } diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserSessionInitializerTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserSessionInitializerTest.java index cf7cc8ab42..9e0358ff09 100644 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserSessionInitializerTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserSessionInitializerTest.java @@ -68,7 +68,7 @@ public class UserSessionInitializerTest { for (UserSessionModel origSession : origSessions) { UserSessionModel userSession = session.sessions().getUserSession(realm, origSession.getId()); for (ClientSessionModel clientSession : userSession.getClientSessions()) { - sessionManager.persistOfflineSession(clientSession, userSession); + sessionManager.createOrUpdateOfflineSession(clientSession, userSession); } } diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserSessionProviderOfflineTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserSessionProviderOfflineTest.java index c843928429..57c99f8ad5 100644 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserSessionProviderOfflineTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/model/UserSessionProviderOfflineTest.java @@ -2,6 +2,7 @@ package org.keycloak.testsuite.model; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; @@ -17,6 +18,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; +import org.keycloak.models.session.UserSessionPersisterProvider; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.services.managers.ClientManager; import org.keycloak.services.managers.RealmManager; @@ -36,6 +38,7 @@ public class UserSessionProviderOfflineTest { private KeycloakSession session; private RealmModel realm; private UserSessionManager sessionManager; + private UserSessionPersisterProvider persister; @Before public void before() { @@ -44,6 +47,7 @@ public class UserSessionProviderOfflineTest { session.users().addUser(realm, "user1").setEmail("user1@localhost"); session.users().addUser(realm, "user2").setEmail("user2@localhost"); sessionManager = new UserSessionManager(session); + persister = session.getProvider(UserSessionPersisterProvider.class); } @After @@ -157,7 +161,7 @@ public class UserSessionProviderOfflineTest { fooRealm = session.realms().getRealm("foo"); userSession = session.sessions().getUserSession(fooRealm, userSession.getId()); clientSession = session.sessions().getClientSession(fooRealm, clientSession.getId()); - sessionManager.persistOfflineSession(userSession.getClientSessions().get(0), userSession); + sessionManager.createOrUpdateOfflineSession(userSession.getClientSessions().get(0), userSession); resetSession(); @@ -291,13 +295,85 @@ public class UserSessionProviderOfflineTest { } + @Test + public void testExpired() { + // Create some online sessions in infinispan + int started = Time.currentTime(); + UserSessionModel[] origSessions = createSessions(); + + resetSession(); + + Map offlineSessions = new HashMap<>(); + + // Persist 3 created userSessions and clientSessions as offline + ClientModel testApp = realm.getClientByClientId("test-app"); + List userSessions = session.sessions().getUserSessions(realm, testApp); + for (UserSessionModel userSession : userSessions) { + offlineSessions.putAll(createOfflineSessionIncludeClientSessions(userSession)); + } + + resetSession(); + + // Assert all previously saved offline sessions found + for (Map.Entry entry : offlineSessions.entrySet()) { + Assert.assertTrue(sessionManager.findOfflineClientSession(realm, entry.getKey(), entry.getValue()) != null); + } + + UserSessionModel session0 = session.sessions().getOfflineUserSession(realm, origSessions[0].getId()); + Assert.assertNotNull(session0); + List clientSessions = new LinkedList<>(); + for (ClientSessionModel clientSession : session0.getClientSessions()) { + clientSessions.add(clientSession.getId()); + Assert.assertNotNull(session.sessions().getOfflineClientSession(realm, clientSession.getId())); + } + + // sessions are in persister too + Assert.assertEquals(3, persister.getUserSessionsCount(true)); + + // Set lastSessionRefresh to session[0] to 0 + session0.setLastSessionRefresh(0); + + resetSession(); + + session.sessions().removeExpiredUserSessions(realm); + + resetSession(); + + // assert sessions not found now + Assert.assertNull(session.sessions().getOfflineUserSession(realm, origSessions[0].getId())); + for (String clientSession : clientSessions) { + Assert.assertNull(session.sessions().getOfflineClientSession(realm, origSessions[0].getId())); + offlineSessions.remove(clientSession); + } + + // Assert other offline sessions still found + for (Map.Entry entry : offlineSessions.entrySet()) { + Assert.assertTrue(sessionManager.findOfflineClientSession(realm, entry.getKey(), entry.getValue()) != null); + } + Assert.assertEquals(2, persister.getUserSessionsCount(true)); + + // Expire everything and assert nothing found + Time.setOffset(3000000); + try { + session.sessions().removeExpiredUserSessions(realm); + + resetSession(); + + for (Map.Entry entry : offlineSessions.entrySet()) { + Assert.assertTrue(sessionManager.findOfflineClientSession(realm, entry.getKey(), entry.getValue()) == null); + } + Assert.assertEquals(0, persister.getUserSessionsCount(true)); + + } finally { + Time.setOffset(0); + } + } + private Map createOfflineSessionIncludeClientSessions(UserSessionModel userSession) { Map offlineSessions = new HashMap<>(); - UserSessionModel offlineUserSession = session.sessions().createOfflineUserSession(userSession); for (ClientSessionModel clientSession : userSession.getClientSessions()) { - ClientSessionModel offlineClientSession = session.sessions().createOfflineClientSession(clientSession); - offlineClientSession.setUserSession(offlineUserSession); + sessionManager.createOrUpdateOfflineSession(clientSession, userSession); offlineSessions.put(clientSession.getId(), userSession.getId()); } return offlineSessions; @@ -310,6 +386,7 @@ public class UserSessionProviderOfflineTest { session = kc.startSession(); realm = session.realms().getRealm("test"); sessionManager = new UserSessionManager(session); + persister = session.getProvider(UserSessionPersisterProvider.class); } private ClientSessionModel createClientSession(ClientModel client, UserSessionModel userSession, String redirect, String state, Set roles, Set protocolMappers) { diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java index 9f95f2617d..370e7fdf58 100644 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java @@ -227,10 +227,27 @@ public class OfflineTokenTest { Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); Assert.assertEquals(0, offlineToken.getExpiration()); - testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, sessionId, userId); + String newRefreshTokenString = testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, sessionId, userId); + + // Change offset to very big value to ensure offline session expires + Time.setOffset(3000000); + + OAuthClient.AccessTokenResponse response = oauth.doRefreshTokenRequest(newRefreshTokenString, "secret1"); + Assert.assertEquals(400, response.getStatusCode()); + assertEquals("invalid_grant", response.getError()); + + events.expectRefresh(offlineToken.getId(), sessionId) + .client("offline-client") + .error(Errors.INVALID_TOKEN) + .user(userId) + .clearDetails() + .assertEvent(); + + + Time.setOffset(0); } - private void testRefreshWithOfflineToken(AccessToken oldToken, RefreshToken offlineToken, String offlineTokenString, + private String testRefreshWithOfflineToken(AccessToken oldToken, RefreshToken offlineToken, String offlineTokenString, final String sessionId, String userId) { // Change offset to big value to ensure userSession expired Time.setOffset(99999); @@ -261,8 +278,9 @@ public class OfflineTokenTest { Assert.assertEquals(200, response.getStatusCode()); Assert.assertEquals(sessionId, refreshedToken.getSessionState()); - // Assert no refreshToken in the response - Assert.assertNull(response.getRefreshToken()); + // Assert new refreshToken in the response + String newRefreshToken = response.getRefreshToken(); + Assert.assertNotNull(newRefreshToken); Assert.assertNotEquals(oldToken.getId(), refreshedToken.getId()); Assert.assertEquals(userId, refreshedToken.getSubject()); @@ -283,6 +301,7 @@ public class OfflineTokenTest { Assert.assertNotEquals(oldToken.getId(), refreshEvent.getDetails().get(Details.TOKEN_ID)); Time.setOffset(0); + return newRefreshToken; } @Test @@ -382,11 +401,11 @@ public class OfflineTokenTest { String accessTokenId = OfflineTokenServlet.tokenInfo.accessToken.getId(); String refreshTokenId = OfflineTokenServlet.tokenInfo.refreshToken.getId(); - // Assert access token will be refreshed, but offline token will be still the same + // Assert access token and offline token are refreshed Time.setOffset(9999); driver.navigate().to(offlineClientAppUri); Assert.assertTrue(driver.getCurrentUrl().startsWith(offlineClientAppUri)); - Assert.assertEquals(OfflineTokenServlet.tokenInfo.refreshToken.getId(), refreshTokenId); + Assert.assertNotEquals(OfflineTokenServlet.tokenInfo.refreshToken.getId(), refreshTokenId); Assert.assertNotEquals(OfflineTokenServlet.tokenInfo.accessToken.getId(), accessTokenId); // Ensure that logout works for webapp (even if offline token will be still valid in Keycloak DB) diff --git a/testsuite/integration/src/test/resources/model/testrealm.json b/testsuite/integration/src/test/resources/model/testrealm.json index 521ef83600..53e7f7ee25 100755 --- a/testsuite/integration/src/test/resources/model/testrealm.json +++ b/testsuite/integration/src/test/resources/model/testrealm.json @@ -4,6 +4,7 @@ "accessTokenLifespan": 6000, "accessCodeLifespan": 30, "accessCodeLifespanUserAction": 600, + "offlineSessionIdleTimeout": 3600000, "requiredCredentials": [ "password" ], "defaultRoles": [ "foo", "bar" ], "verifyEmail" : "true",