From cf57a1bc4bbd1f56d75334fb0aa83457b3805a7e Mon Sep 17 00:00:00 2001 From: Thomas Darimont Date: Tue, 23 Aug 2016 00:40:23 +0200 Subject: [PATCH] KEYCLOAK-1267 Add dedicated SSO timeouts for Remember-Me Previously remember-me sessions where tied to the SSO max session timeout which could lead to unexpected early session timeouts. We now allow SSO timeouts to be configured separately for sessions with enabled remember-me. This enables users to opt-in for longer session timeouts. SSO session timeouts for remember-me can now be configured in the tokens tab in the realm admin console. This new configuration is optional and will tipically host values larger than the regular max SSO timeouts. If no value is specified for remember-me timeouts then the regular max SSO timeouts will be used. Work based on PR https://github.com/keycloak/keycloak/pull/3161 by Thomas Darimont --- .../idm/RealmRepresentation.java | 18 +++ .../models/cache/infinispan/RealmAdapter.java | 24 ++++ .../infinispan/entities/CachedRealm.java | 12 ++ .../InfinispanUserSessionProvider.java | 5 +- .../stream/UserSessionPredicate.java | 23 +++- .../org/keycloak/models/jpa/RealmAdapter.java | 20 ++++ .../models/jpa/entities/RealmEntity.java | 20 ++++ .../META-INF/jpa-changelog-4.7.0.xml | 27 +++++ .../META-INF/jpa-changelog-master.xml | 1 + .../models/utils/ModelToRepresentation.java | 2 + .../models/utils/RepresentationToModel.java | 4 + .../java/org/keycloak/models/RealmModel.java | 6 + .../freemarker/model/SessionsBean.java | 3 +- .../keycloak/protocol/oidc/TokenManager.java | 12 +- .../managers/AuthenticationManager.java | 21 +++- .../keycloak/testsuite/util/OAuthClient.java | 14 +++ .../testsuite/admin/realm/RealmTest.java | 6 + .../exportimport/ExportImportUtil.java | 4 + .../keycloak/testsuite/forms/LoginTest.java | 65 +++++++++++ .../testsuite/oauth/RefreshTokenTest.java | 109 ++++++++++++++++++ .../keycloak/testsuite/util/RealmBuilder.java | 10 ++ .../src/test/resources/model/testrealm.json | 4 + .../messages/admin-messages_en.properties | 4 + .../admin/resources/js/controllers/realm.js | 4 + .../resources/partials/realm-tokens.html | 32 +++++ 25 files changed, 435 insertions(+), 15 deletions(-) create mode 100644 model/jpa/src/main/resources/META-INF/jpa-changelog-4.7.0.xml 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 8da979e1a0..940c457c9f 100755 --- a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java @@ -44,6 +44,8 @@ public class RealmRepresentation { protected Integer accessTokenLifespanForImplicitFlow; protected Integer ssoSessionIdleTimeout; protected Integer ssoSessionMaxLifespan; + protected Integer ssoSessionIdleTimeoutRememberMe; + protected Integer ssoSessionMaxLifespanRememberMe; protected Integer offlineSessionIdleTimeout; // KEYCLOAK-7688 Offline Session Max for Offline Token protected Boolean offlineSessionMaxLifespanEnabled; @@ -300,6 +302,22 @@ public class RealmRepresentation { this.ssoSessionMaxLifespan = ssoSessionMaxLifespan; } + public Integer getSsoSessionMaxLifespanRememberMe() { + return ssoSessionMaxLifespanRememberMe; + } + + public void setSsoSessionMaxLifespanRememberMe(Integer ssoSessionMaxLifespanRememberMe) { + this.ssoSessionMaxLifespanRememberMe = ssoSessionMaxLifespanRememberMe; + } + + public Integer getSsoSessionIdleTimeoutRememberMe() { + return ssoSessionIdleTimeoutRememberMe; + } + + public void setSsoSessionIdleTimeoutRememberMe(Integer ssoSessionIdleTimeoutRememberMe) { + this.ssoSessionIdleTimeoutRememberMe = ssoSessionIdleTimeoutRememberMe; + } + public Integer getOfflineSessionIdleTimeout() { return offlineSessionIdleTimeout; } diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java index 0771469aaf..62aee89e66 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java @@ -417,6 +417,30 @@ public class RealmAdapter implements CachedRealmModel { updated.setSsoSessionMaxLifespan(seconds); } + @Override + public int getSsoSessionIdleTimeoutRememberMe() { + if (updated != null) return updated.getSsoSessionIdleTimeoutRememberMe(); + return cached.getSsoSessionIdleTimeoutRememberMe(); + } + + @Override + public void setSsoSessionIdleTimeoutRememberMe(int seconds) { + getDelegateForUpdate(); + updated.setSsoSessionIdleTimeoutRememberMe(seconds); + } + + @Override + public int getSsoSessionMaxLifespanRememberMe() { + if (updated != null) return updated.getSsoSessionMaxLifespanRememberMe(); + return cached.getSsoSessionMaxLifespanRememberMe(); + } + + @Override + public void setSsoSessionMaxLifespanRememberMe(int seconds) { + getDelegateForUpdate(); + updated.setSsoSessionMaxLifespanRememberMe(seconds); + } + @Override public int getOfflineSessionIdleTimeout() { if (isUpdated()) return updated.getOfflineSessionIdleTimeout(); diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java index 389724b90d..c2877e1b3d 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java @@ -79,6 +79,8 @@ public class CachedRealm extends AbstractExtendableRevisioned { protected int refreshTokenMaxReuse; protected int ssoSessionIdleTimeout; protected int ssoSessionMaxLifespan; + protected int ssoSessionIdleTimeoutRememberMe; + protected int ssoSessionMaxLifespanRememberMe; protected int offlineSessionIdleTimeout; // KEYCLOAK-7688 Offline Session Max for Offline Token protected boolean offlineSessionMaxLifespanEnabled; @@ -185,6 +187,8 @@ public class CachedRealm extends AbstractExtendableRevisioned { refreshTokenMaxReuse = model.getRefreshTokenMaxReuse(); ssoSessionIdleTimeout = model.getSsoSessionIdleTimeout(); ssoSessionMaxLifespan = model.getSsoSessionMaxLifespan(); + ssoSessionIdleTimeoutRememberMe = model.getSsoSessionIdleTimeoutRememberMe(); + ssoSessionMaxLifespanRememberMe = model.getSsoSessionMaxLifespanRememberMe(); offlineSessionIdleTimeout = model.getOfflineSessionIdleTimeout(); // KEYCLOAK-7688 Offline Session Max for Offline Token offlineSessionMaxLifespanEnabled = model.isOfflineSessionMaxLifespanEnabled(); @@ -413,6 +417,14 @@ public class CachedRealm extends AbstractExtendableRevisioned { return ssoSessionMaxLifespan; } + public int getSsoSessionIdleTimeoutRememberMe() { + return ssoSessionIdleTimeoutRememberMe; + } + + public int getSsoSessionMaxLifespanRememberMe() { + return ssoSessionMaxLifespanRememberMe; + } + public int getOfflineSessionIdleTimeout() { return offlineSessionIdleTimeout; } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java index 1e1670d11b..bf7b04f2d3 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java @@ -470,6 +470,9 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { private void removeExpiredUserSessions(RealmModel realm) { int expired = Time.currentTime() - realm.getSsoSessionMaxLifespan(); int expiredRefresh = Time.currentTime() - realm.getSsoSessionIdleTimeout() - SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; + int expiredRememberMe = Time.currentTime() - (realm.getSsoSessionMaxLifespanRememberMe() > 0 ? realm.getSsoSessionMaxLifespanRememberMe() : realm.getSsoSessionMaxLifespan()); + int expiredRefreshRememberMe = Time.currentTime() - (realm.getSsoSessionIdleTimeoutRememberMe() > 0 ? realm.getSsoSessionIdleTimeoutRememberMe() : realm.getSsoSessionIdleTimeout()) - + SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS; FuturesHelper futures = new FuturesHelper(); @@ -484,7 +487,7 @@ public class InfinispanUserSessionProvider implements UserSessionProvider { localCacheStoreIgnore .entrySet() .stream() - .filter(UserSessionPredicate.create(realm.getId()).expired(expired, expiredRefresh)) + .filter(UserSessionPredicate.create(realm.getId()).expired(expired, expiredRefresh, expiredRememberMe, expiredRefreshRememberMe)) .map(Mappers.userSessionEntity()) .forEach(new Consumer() { diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/UserSessionPredicate.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/UserSessionPredicate.java index 850d19e0ad..7ec245cc65 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/UserSessionPredicate.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/stream/UserSessionPredicate.java @@ -47,6 +47,10 @@ public class UserSessionPredicate implements Predicate expired && entity.getLastSessionRefresh() > expiredRefresh) { - return false; + if (entity.isRememberMe()) { + if (expiredRememberMe != null && expiredRefreshRememberMe != null && entity.getStarted() > expiredRefreshRememberMe && entity.getLastSessionRefresh() > expiredRefreshRememberMe) { + return false; + } + } + else { + if (expired != null && expiredRefresh != null && entity.getStarted() > expired && entity.getLastSessionRefresh() > expiredRefresh) { + return false; + } } if (expired == null && expiredRefresh != null && entity.getLastSessionRefresh() > expiredRefresh) { return false; } - return true; } 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 c699ec5a09..f5e45cf6ef 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 @@ -466,6 +466,26 @@ public class RealmAdapter implements RealmModel, JpaModel { realm.setSsoSessionMaxLifespan(seconds); } + @Override + public int getSsoSessionIdleTimeoutRememberMe() { + return realm.getSsoSessionIdleTimeoutRememberMe(); + } + + @Override + public void setSsoSessionIdleTimeoutRememberMe(int seconds){ + realm.setSsoSessionIdleTimeoutRememberMe(seconds); + } + + @Override + public int getSsoSessionMaxLifespanRememberMe() { + return realm.getSsoSessionMaxLifespanRememberMe(); + } + + @Override + public void setSsoSessionMaxLifespanRememberMe(int seconds) { + realm.setSsoSessionMaxLifespanRememberMe(seconds); + } + @Override public int getOfflineSessionIdleTimeout() { return realm.getOfflineSessionIdleTimeout(); 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 6fbf1c013d..973b2a09e4 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 @@ -109,6 +109,10 @@ public class RealmEntity { private int ssoSessionIdleTimeout; @Column(name="SSO_MAX_LIFESPAN") private int ssoSessionMaxLifespan; + @Column(name="SSO_IDLE_TIMEOUT_REMEMBER_ME") + private int ssoSessionIdleTimeoutRememberMe; + @Column(name="SSO_MAX_LIFESPAN_REMEMBER_ME") + private int ssoSessionMaxLifespanRememberMe; @Column(name="OFFLINE_SESSION_IDLE_TIMEOUT") private int offlineSessionIdleTimeout; @Column(name="ACCESS_TOKEN_LIFESPAN") @@ -370,6 +374,22 @@ public class RealmEntity { this.ssoSessionMaxLifespan = ssoSessionMaxLifespan; } + public int getSsoSessionIdleTimeoutRememberMe() { + return ssoSessionIdleTimeoutRememberMe; + } + + public void setSsoSessionIdleTimeoutRememberMe(int ssoSessionIdleTimeoutRememberMe) { + this.ssoSessionIdleTimeoutRememberMe = ssoSessionIdleTimeoutRememberMe; + } + + public int getSsoSessionMaxLifespanRememberMe() { + return ssoSessionMaxLifespanRememberMe; + } + + public void setSsoSessionMaxLifespanRememberMe(int ssoSessionMaxLifespanRememberMe) { + this.ssoSessionMaxLifespanRememberMe = ssoSessionMaxLifespanRememberMe; + } + public int getOfflineSessionIdleTimeout() { return offlineSessionIdleTimeout; } diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-4.7.0.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-4.7.0.xml new file mode 100644 index 0000000000..f8c969330f --- /dev/null +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-4.7.0.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml index 9a3e021fa4..232a0edf08 100755 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-master.xml @@ -60,4 +60,5 @@ + diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 0697c3874a..33b026e994 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -293,6 +293,8 @@ public class ModelToRepresentation { rep.setAccessTokenLifespanForImplicitFlow(realm.getAccessTokenLifespanForImplicitFlow()); rep.setSsoSessionIdleTimeout(realm.getSsoSessionIdleTimeout()); rep.setSsoSessionMaxLifespan(realm.getSsoSessionMaxLifespan()); + rep.setSsoSessionIdleTimeoutRememberMe(realm.getSsoSessionIdleTimeoutRememberMe()); + rep.setSsoSessionMaxLifespanRememberMe(realm.getSsoSessionMaxLifespanRememberMe()); rep.setOfflineSessionIdleTimeout(realm.getOfflineSessionIdleTimeout()); // KEYCLOAK-7688 Offline Session Max for Offline Token rep.setOfflineSessionMaxLifespanEnabled(realm.isOfflineSessionMaxLifespanEnabled()); diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index 1c7bcf398c..e2b25fc90a 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -195,6 +195,8 @@ public class RepresentationToModel { else newRealm.setSsoSessionIdleTimeout(1800); if (rep.getSsoSessionMaxLifespan() != null) newRealm.setSsoSessionMaxLifespan(rep.getSsoSessionMaxLifespan()); else newRealm.setSsoSessionMaxLifespan(36000); + if (rep.getSsoSessionMaxLifespanRememberMe() != null) newRealm.setSsoSessionMaxLifespanRememberMe(rep.getSsoSessionMaxLifespanRememberMe()); + if (rep.getSsoSessionIdleTimeoutRememberMe() != null) newRealm.setSsoSessionIdleTimeoutRememberMe(rep.getSsoSessionIdleTimeoutRememberMe()); if (rep.getOfflineSessionIdleTimeout() != null) newRealm.setOfflineSessionIdleTimeout(rep.getOfflineSessionIdleTimeout()); else newRealm.setOfflineSessionIdleTimeout(Constants.DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT); @@ -916,6 +918,8 @@ public class RepresentationToModel { realm.setAccessTokenLifespanForImplicitFlow(rep.getAccessTokenLifespanForImplicitFlow()); if (rep.getSsoSessionIdleTimeout() != null) realm.setSsoSessionIdleTimeout(rep.getSsoSessionIdleTimeout()); if (rep.getSsoSessionMaxLifespan() != null) realm.setSsoSessionMaxLifespan(rep.getSsoSessionMaxLifespan()); + if (rep.getSsoSessionIdleTimeoutRememberMe() != null) realm.setSsoSessionIdleTimeoutRememberMe(rep.getSsoSessionIdleTimeoutRememberMe()); + if (rep.getSsoSessionMaxLifespanRememberMe() != null) realm.setSsoSessionMaxLifespanRememberMe(rep.getSsoSessionMaxLifespanRememberMe()); if (rep.getOfflineSessionIdleTimeout() != null) realm.setOfflineSessionIdleTimeout(rep.getOfflineSessionIdleTimeout()); // KEYCLOAK-7688 Offline Session Max for Offline Token diff --git a/server-spi/src/main/java/org/keycloak/models/RealmModel.java b/server-spi/src/main/java/org/keycloak/models/RealmModel.java index c8d3d9b121..33323fd1f1 100755 --- a/server-spi/src/main/java/org/keycloak/models/RealmModel.java +++ b/server-spi/src/main/java/org/keycloak/models/RealmModel.java @@ -177,6 +177,12 @@ public interface RealmModel extends RoleContainerModel { int getSsoSessionMaxLifespan(); void setSsoSessionMaxLifespan(int seconds); + int getSsoSessionIdleTimeoutRememberMe(); + void setSsoSessionIdleTimeoutRememberMe(int seconds); + + int getSsoSessionMaxLifespanRememberMe(); + void setSsoSessionMaxLifespanRememberMe(int seconds); + int getOfflineSessionIdleTimeout(); void setOfflineSessionIdleTimeout(int seconds); diff --git a/services/src/main/java/org/keycloak/forms/account/freemarker/model/SessionsBean.java b/services/src/main/java/org/keycloak/forms/account/freemarker/model/SessionsBean.java index f597d5c227..dcb7ced7c9 100755 --- a/services/src/main/java/org/keycloak/forms/account/freemarker/model/SessionsBean.java +++ b/services/src/main/java/org/keycloak/forms/account/freemarker/model/SessionsBean.java @@ -72,7 +72,8 @@ public class SessionsBean { } public Date getExpires() { - int max = session.getStarted() + realm.getSsoSessionMaxLifespan(); + int maxLifespan = session.isRememberMe() && realm.getSsoSessionMaxLifespanRememberMe() > 0 ? realm.getSsoSessionMaxLifespanRememberMe() : realm.getSsoSessionMaxLifespan(); + int max = session.getStarted() + maxLifespan; return Time.toDate(max); } 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 a007c718da..fc3c584f98 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -656,13 +656,15 @@ public class TokenManager { int expiration; if (tokenLifespan == -1) { - expiration = userSession.getStarted() + realm.getSsoSessionMaxLifespan(); + expiration = userSession.getStarted() + (userSession.isRememberMe() && realm.getSsoSessionMaxLifespanRememberMe() > 0 ? + realm.getSsoSessionMaxLifespanRememberMe() : realm.getSsoSessionMaxLifespan()); } else { expiration = Time.currentTime() + tokenLifespan; } if (!userSession.isOffline()) { - int sessionExpires = userSession.getStarted() + realm.getSsoSessionMaxLifespan(); + int sessionExpires = userSession.getStarted() + (userSession.isRememberMe() && realm.getSsoSessionMaxLifespanRememberMe() > 0 ? + realm.getSsoSessionMaxLifespanRememberMe() : realm.getSsoSessionMaxLifespan()); expiration = expiration <= sessionExpires ? expiration : sessionExpires; } @@ -756,8 +758,10 @@ public class TokenManager { } private int getRefreshExpiration() { - int sessionExpires = userSession.getStarted() + realm.getSsoSessionMaxLifespan(); - int expiration = Time.currentTime() + realm.getSsoSessionIdleTimeout(); + int sessionExpires = userSession.getStarted() + (userSession.isRememberMe() && realm.getSsoSessionMaxLifespanRememberMe() > 0 ? + realm.getSsoSessionMaxLifespanRememberMe() : realm.getSsoSessionMaxLifespan()); + int expiration = Time.currentTime() + (userSession.isRememberMe() && realm.getSsoSessionIdleTimeoutRememberMe() > 0 ? + realm.getSsoSessionIdleTimeoutRememberMe() : realm.getSsoSessionIdleTimeout()); return expiration <= sessionExpires ? expiration : sessionExpires; } diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index d8866cce27..eac4cb7607 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -130,12 +130,16 @@ public class AuthenticationManager { return false; } int currentTime = Time.currentTime(); - int max = userSession.getStarted() + realm.getSsoSessionMaxLifespan(); // Additional time window is added for the case when session was updated in different DC and the update to current DC was postponed - int maxIdle = realm.getSsoSessionIdleTimeout() + SessionTimeoutHelper.IDLE_TIMEOUT_WINDOW_SECONDS; + int maxIdle = (userSession.isRememberMe() && realm.getSsoSessionIdleTimeoutRememberMe() > 0 ? + realm.getSsoSessionIdleTimeoutRememberMe() : realm.getSsoSessionIdleTimeout()) + SessionTimeoutHelper.IDLE_TIMEOUT_WINDOW_SECONDS; + int maxLifespan = userSession.isRememberMe() && realm.getSsoSessionMaxLifespanRememberMe() > 0 ? + realm.getSsoSessionMaxLifespanRememberMe() : realm.getSsoSessionMaxLifespan(); - return userSession.getLastSessionRefresh() + maxIdle > currentTime && max > currentTime; + boolean sessionMaxOk = userSession.getStarted() + maxLifespan > currentTime; + boolean sessionIdleOk = userSession.getLastSessionRefresh() + maxIdle > currentTime; + return sessionIdleOk && sessionMaxOk; } public static boolean isOfflineSessionValid(RealmModel realm, UserSessionModel userSession) { @@ -576,10 +580,14 @@ public class AuthenticationManager { token.issuedNow(); token.subject(user.getId()); token.issuer(issuer); + if (session != null) { token.setSessionState(session.getId()); } - if (realm.getSsoSessionMaxLifespan() > 0) { + + if (session != null && session.isRememberMe() && realm.getSsoSessionMaxLifespanRememberMe() > 0) { + token.expiration(Time.currentTime() + realm.getSsoSessionMaxLifespanRememberMe()); + } else if (realm.getSsoSessionMaxLifespan() > 0) { token.expiration(Time.currentTime() + realm.getSsoSessionMaxLifespan()); } @@ -601,7 +609,7 @@ public class AuthenticationManager { boolean secureOnly = realm.getSslRequired().isRequired(connection); int maxAge = NewCookie.DEFAULT_MAX_AGE; if (session != null && session.isRememberMe()) { - maxAge = realm.getSsoSessionMaxLifespan(); + maxAge = realm.getSsoSessionMaxLifespanRememberMe() > 0 ? realm.getSsoSessionMaxLifespanRememberMe() : realm.getSsoSessionMaxLifespan(); } logger.debugv("Create login cookie - name: {0}, path: {1}, max-age: {2}", KEYCLOAK_IDENTITY_COOKIE, cookiePath, maxAge); CookieHelper.addCookie(KEYCLOAK_IDENTITY_COOKIE, encoded, cookiePath, null, null, maxAge, secureOnly, true); @@ -613,7 +621,8 @@ public class AuthenticationManager { } // THIS SHOULD NOT BE A HTTPONLY COOKIE! It is used for OpenID Connect Iframe Session support! // Max age should be set to the max lifespan of the session as it's used to invalidate old-sessions on re-login - CookieHelper.addCookie(KEYCLOAK_SESSION_COOKIE, sessionCookieValue, cookiePath, null, null, realm.getSsoSessionMaxLifespan(), secureOnly, false); + int sessionCookieMaxAge = session.isRememberMe() && realm.getSsoSessionMaxLifespanRememberMe() > 0 ? realm.getSsoSessionMaxLifespanRememberMe() : realm.getSsoSessionMaxLifespan(); + CookieHelper.addCookie(KEYCLOAK_SESSION_COOKIE, sessionCookieValue, cookiePath, null, null, sessionCookieMaxAge, secureOnly, false); P3PHelper.addP3PHeader(keycloakSession); } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index 81bcd04521..f7d8c5538f 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -227,12 +227,26 @@ public class OAuthClient { return doLogin(user.getUsername(), getPasswordOf(user)); } + public AuthorizationEndpointResponse doRememberMeLogin(String username, String password) { + openLoginForm(); + fillLoginForm(username, password, true); + + return new AuthorizationEndpointResponse(this); + } + public void fillLoginForm(String username, String password) { + this.fillLoginForm(username, password, false); + } + + public void fillLoginForm(String username, String password, boolean rememberMe) { WaitUtils.waitForPageToLoad(); String src = driver.getPageSource(); try { driver.findElement(By.id("username")).sendKeys(username); driver.findElement(By.id("password")).sendKeys(password); + if (rememberMe) { + driver.findElement(By.id("rememberMe")).click(); + } driver.findElement(By.name("login")).click(); } catch (Throwable t) { System.err.println(src); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java index f619666649..40cf19c54a 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java @@ -364,6 +364,8 @@ public class RealmTest extends AbstractAdminTest { RealmRepresentation rep = realm.toRepresentation(); rep.setSsoSessionIdleTimeout(123); rep.setSsoSessionMaxLifespan(12); + rep.setSsoSessionIdleTimeoutRememberMe(33); + rep.setSsoSessionMaxLifespanRememberMe(34); rep.setAccessCodeLifespanLogin(1234); rep.setActionTokenGeneratedByAdminLifespan(2345); rep.setActionTokenGeneratedByUserLifespan(3456); @@ -379,6 +381,8 @@ public class RealmTest extends AbstractAdminTest { assertEquals(123, rep.getSsoSessionIdleTimeout().intValue()); assertEquals(12, rep.getSsoSessionMaxLifespan().intValue()); + assertEquals(33, rep.getSsoSessionIdleTimeoutRememberMe().intValue()); + assertEquals(34, rep.getSsoSessionMaxLifespanRememberMe().intValue()); assertEquals(1234, rep.getAccessCodeLifespanLogin().intValue()); assertEquals(2345, rep.getActionTokenGeneratedByAdminLifespan().intValue()); assertEquals(3456, rep.getActionTokenGeneratedByUserLifespan().intValue()); @@ -571,6 +575,8 @@ public class RealmTest extends AbstractAdminTest { if (realm.getAccessTokenLifespanForImplicitFlow() != null) assertEquals(realm.getAccessTokenLifespanForImplicitFlow(), storedRealm.getAccessTokenLifespanForImplicitFlow()); if (realm.getSsoSessionIdleTimeout() != null) assertEquals(realm.getSsoSessionIdleTimeout(), storedRealm.getSsoSessionIdleTimeout()); if (realm.getSsoSessionMaxLifespan() != null) assertEquals(realm.getSsoSessionMaxLifespan(), storedRealm.getSsoSessionMaxLifespan()); + if (realm.getSsoSessionIdleTimeoutRememberMe() != null) Assert.assertEquals(realm.getSsoSessionIdleTimeoutRememberMe(), storedRealm.getSsoSessionIdleTimeoutRememberMe()); + if (realm.getSsoSessionMaxLifespanRememberMe() != null) Assert.assertEquals(realm.getSsoSessionMaxLifespanRememberMe(), storedRealm.getSsoSessionMaxLifespanRememberMe()); if (realm.getRequiredCredentials() != null) { assertNotNull(storedRealm.getRequiredCredentials()); for (String cred : realm.getRequiredCredentials()) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java index 17cd9b6cce..e8ae1b7276 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java @@ -82,6 +82,10 @@ public class ExportImportUtil { Assert.assertTrue(realm.isVerifyEmail()); Assert.assertEquals((Integer)3600000, realm.getOfflineSessionIdleTimeout()); Assert.assertEquals((Integer)1500, realm.getAccessTokenLifespanForImplicitFlow()); + Assert.assertEquals((Integer)1800, realm.getSsoSessionIdleTimeout()); + Assert.assertEquals((Integer)36000, realm.getSsoSessionMaxLifespan()); + Assert.assertEquals((Integer)3600, realm.getSsoSessionIdleTimeoutRememberMe()); + Assert.assertEquals((Integer)172800, realm.getSsoSessionMaxLifespanRememberMe()); Set creds = realm.getRequiredCredentials(); Assert.assertEquals(1, creds.size()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java index 7b946dd785..17c5724213 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java @@ -30,6 +30,7 @@ import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.JWSInputException; import org.keycloak.models.BrowserSecurityHeaders; import org.keycloak.models.Constants; +import org.keycloak.models.utils.SessionTimeoutHelper; import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.EventRepresentation; @@ -526,8 +527,14 @@ public class LoginTest extends AbstractTestRealmKeycloakTest { } private void setRememberMe(boolean enabled) { + this.setRememberMe(enabled, null, null); + } + + private void setRememberMe(boolean enabled, Integer idleTimeout, Integer maxLifespan) { RealmRepresentation rep = adminClient.realm("test").toRepresentation(); rep.setRememberMe(enabled); + rep.setSsoSessionIdleTimeoutRememberMe(idleTimeout); + rep.setSsoSessionMaxLifespanRememberMe(maxLifespan); adminClient.realm("test").update(rep); } @@ -749,4 +756,62 @@ public class LoginTest extends AbstractTestRealmKeycloakTest { events.expectLogin().detail(Details.USERNAME, "test-user@localhost").assertEvent(); } + @Test + public void loginRememberMeExpiredIdle() throws Exception { + setRememberMe(true, 1, null); + + try { + // login form shown after redirect from app + oauth.clientId("test-app"); + oauth.redirectUri(OAuthClient.APP_ROOT + "/auth"); + oauth.openLoginForm(); + + assertTrue(loginPage.isCurrent()); + loginPage.setRememberMe(true); + loginPage.login("test-user@localhost", "password"); + + // sucessful login - app page should be on display. + events.expectLogin().detail(Details.USERNAME, "test-user@localhost").assertEvent(); + appPage.assertCurrent(); + + // expire idle timeout using the timeout window. + setTimeOffset(2 + SessionTimeoutHelper.IDLE_TIMEOUT_WINDOW_SECONDS); + + // trying to open the account page with an expired idle timeout should redirect back to the login page. + appPage.openAccount(); + loginPage.assertCurrent(); + } finally { + setRememberMe(false); + } + } + + @Test + public void loginRememberMeExpiredMaxLifespan() throws Exception { + setRememberMe(true, null, 1); + + try { + // login form shown after redirect from app + oauth.clientId("test-app"); + oauth.redirectUri(OAuthClient.APP_ROOT + "/auth"); + oauth.openLoginForm(); + + assertTrue(loginPage.isCurrent()); + loginPage.setRememberMe(true); + loginPage.login("test-user@localhost", "password"); + + // sucessful login - app page should be on display. + events.expectLogin().detail(Details.USERNAME, "test-user@localhost").assertEvent(); + appPage.assertCurrent(); + + // expire the max lifespan. + setTimeOffset(2); + + // trying to open the account page with an expired lifespan should redirect back to the login page. + appPage.openAccount(); + loginPage.assertCurrent(); + } finally { + setRememberMe(false); + } + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java index 8facb4127c..d74c9858a6 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java @@ -592,6 +592,64 @@ public class RefreshTokenTest extends AbstractKeycloakTest { setTimeOffset(0); } + @Test + public void testUserSessionRefreshAndIdleRememberMe() throws Exception { + RealmResource testRealm = adminClient.realm("test"); + RealmRepresentation testRealmRep = testRealm.toRepresentation(); + Boolean previousRememberMe = testRealmRep.isRememberMe(); + int originalIdleRememberMe = testRealmRep.getSsoSessionIdleTimeoutRememberMe(); + + try { + testRealmRep.setRememberMe(true); + testRealm.update(testRealmRep); + + oauth.doRememberMeLogin("test-user@localhost", "password"); + + EventRepresentation loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password"); + + events.poll(); + + String refreshId = oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getId(); + int last = testingClient.testing().getLastSessionRefresh("test", sessionId, false); + + setTimeOffset(2); + tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken(), "password"); + oauth.verifyToken(tokenResponse.getAccessToken()); + oauth.parseRefreshToken(tokenResponse.getRefreshToken()); + assertEquals(200, tokenResponse.getStatusCode()); + + int next = testingClient.testing().getLastSessionRefresh("test", sessionId, false); + Assert.assertNotEquals(last, next); + + testRealmRep.setSsoSessionIdleTimeoutRememberMe(1); + testRealm.update(testRealmRep); + + events.clear(); + // Needs to add some additional time due the tollerance allowed by IDLE_TIMEOUT_WINDOW_SECONDS + setTimeOffset(6 + SessionTimeoutHelper.IDLE_TIMEOUT_WINDOW_SECONDS); + tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken(), "password"); + + // test idle remember me timeout + assertEquals(400, tokenResponse.getStatusCode()); + assertNull(tokenResponse.getAccessToken()); + assertNull(tokenResponse.getRefreshToken()); + + events.expectRefresh(refreshId, sessionId).error(Errors.INVALID_TOKEN); + events.clear(); + + } finally { + testRealmRep.setSsoSessionIdleTimeoutRememberMe(originalIdleRememberMe); + testRealmRep.setRememberMe(previousRememberMe); + testRealm.update(testRealmRep); + setTimeOffset(0); + } + } + @Test public void refreshTokenUserSessionMaxLifespan() throws Exception { oauth.doLogin("test-user@localhost", "password"); @@ -628,6 +686,57 @@ public class RefreshTokenTest extends AbstractKeycloakTest { setTimeOffset(0); } + /** + * KEYCLOAK-1267 + * @throws Exception + */ + @Test + public void refreshTokenUserSessionMaxLifespanWithRememberMe() throws Exception { + + RealmResource testRealm = adminClient.realm("test"); + RealmRepresentation testRealmRep = testRealm.toRepresentation(); + Boolean previousRememberMe = testRealmRep.isRememberMe(); + int previousSsoMaxLifespanRememberMe = testRealmRep.getSsoSessionMaxLifespanRememberMe(); + + try { + testRealmRep.setRememberMe(true); + testRealm.update(testRealmRep); + + oauth.doRememberMeLogin("test-user@localhost", "password"); + + EventRepresentation loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password"); + + events.poll(); + + String refreshId = oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getId(); + + testRealmRep.setSsoSessionMaxLifespanRememberMe(1); + testRealm.update(testRealmRep); + + setTimeOffset(2); + + tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken(), "password"); + + assertEquals(400, tokenResponse.getStatusCode()); + assertNull(tokenResponse.getAccessToken()); + assertNull(tokenResponse.getRefreshToken()); + + events.expectRefresh(refreshId, sessionId).error(Errors.INVALID_TOKEN); + events.clear(); + + } finally { + testRealmRep.setSsoSessionMaxLifespanRememberMe(previousSsoMaxLifespanRememberMe); + testRealmRep.setRememberMe(previousRememberMe); + testRealm.update(testRealmRep); + setTimeOffset(0); + } + } + @Test public void testCheckSsl() throws Exception { Client client = ClientBuilder.newClient(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmBuilder.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmBuilder.java index 50cdcbad60..cb5a0cf7bc 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/RealmBuilder.java @@ -198,6 +198,16 @@ public class RealmBuilder { return this; } + public RealmBuilder ssoSessionIdleTimeoutRememberMe(int ssoSessionIdleTimeoutRememberMe){ + rep.setSsoSessionIdleTimeoutRememberMe(ssoSessionIdleTimeoutRememberMe); + return this; + } + + public RealmBuilder ssoSessionMaxLifespanRememberMe(int ssoSessionMaxLifespanRememberMe){ + rep.setSsoSessionMaxLifespanRememberMe(ssoSessionMaxLifespanRememberMe); + return this; + } + public RealmBuilder accessCodeLifespanUserAction(int accessCodeLifespanUserAction) { rep.setAccessCodeLifespanUserAction(accessCodeLifespanUserAction); return this; diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/model/testrealm.json b/testsuite/integration-arquillian/tests/base/src/test/resources/model/testrealm.json index a15128c4ee..0237bf233e 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/model/testrealm.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/model/testrealm.json @@ -3,6 +3,10 @@ "enabled": true, "accessTokenLifespan": 6000, "accessTokenLifespanForImplicitFlow": 1500, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 3600, + "ssoSessionMaxLifespanRememberMe": 172800, "accessCodeLifespan": 30, "accessCodeLifespanUserAction": 600, "offlineSessionIdleTimeout": 3600000, diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index 175a5a1e66..2a9a39628c 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -109,6 +109,10 @@ 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. +sso-session-idle-remember-me=SSO Session Idle Remember Me +sso-session-idle-remember-me.tooltip=Time a remember me session is allowed to be idle before it expires. Tokens and browser sessions are invalidated when a session is expired. If not set it uses the standard SSO Session Idle value. +sso-session-max-remember-me=SSO Session Max Remember Me +sso-session-max-remember-me.tooltip=Max time before a session is expired when the user has set the remember me option. Tokens and browser sessions are invalidated when a session is expired. If not set it uses the standard SSO Session Max value. 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. realm-detail.hostname=Hostname diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js index 802f1ea6cd..936a7c1be4 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js @@ -1087,6 +1087,8 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $scope.realm.accessTokenLifespanForImplicitFlow = TimeUnit2.asUnit(realm.accessTokenLifespanForImplicitFlow); $scope.realm.ssoSessionIdleTimeout = TimeUnit2.asUnit(realm.ssoSessionIdleTimeout); $scope.realm.ssoSessionMaxLifespan = TimeUnit2.asUnit(realm.ssoSessionMaxLifespan); + $scope.realm.ssoSessionIdleTimeoutRememberMe = TimeUnit2.asUnit(realm.ssoSessionIdleTimeoutRememberMe); + $scope.realm.ssoSessionMaxLifespanRememberMe = TimeUnit2.asUnit(realm.ssoSessionMaxLifespanRememberMe); $scope.realm.offlineSessionIdleTimeout = TimeUnit2.asUnit(realm.offlineSessionIdleTimeout); // KEYCLOAK-7688 Offline Session Max for Offline Token $scope.realm.offlineSessionMaxLifespan = TimeUnit2.asUnit(realm.offlineSessionMaxLifespan); @@ -1140,6 +1142,8 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $scope.realm.accessTokenLifespanForImplicitFlow = $scope.realm.accessTokenLifespanForImplicitFlow.toSeconds(); $scope.realm.ssoSessionIdleTimeout = $scope.realm.ssoSessionIdleTimeout.toSeconds(); $scope.realm.ssoSessionMaxLifespan = $scope.realm.ssoSessionMaxLifespan.toSeconds(); + $scope.realm.ssoSessionIdleTimeoutRememberMe = $scope.realm.ssoSessionIdleTimeoutRememberMe.toSeconds(); + $scope.realm.ssoSessionMaxLifespanRememberMe = $scope.realm.ssoSessionMaxLifespanRememberMe.toSeconds(); $scope.realm.offlineSessionIdleTimeout = $scope.realm.offlineSessionIdleTimeout.toSeconds(); // KEYCLOAK-7688 Offline Session Max for Offline Token $scope.realm.offlineSessionMaxLifespan = $scope.realm.offlineSessionMaxLifespan.toSeconds(); diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html b/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html index fbaa037ad2..f31ca1b2f6 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html @@ -71,6 +71,38 @@ {{:: 'sso-session-max.tooltip' | translate}} +
+ + +
+ + +
+ {{:: 'sso-session-idle-remember-me.tooltip' | translate}} +
+ +
+ + +
+ + +
+ {{:: 'sso-session-max-remember-me.tooltip' | translate}} +
+