KEYCLOAK-7688 Offline Session Max for Offline Token

This commit is contained in:
Takashi Norimatsu 2018-06-25 09:27:50 +09:00 committed by Marek Posolda
parent b478472b35
commit 2fb022e501
16 changed files with 259 additions and 2 deletions

View file

@ -44,6 +44,9 @@ public class RealmRepresentation {
protected Integer ssoSessionIdleTimeout;
protected Integer ssoSessionMaxLifespan;
protected Integer offlineSessionIdleTimeout;
// KEYCLOAK-7688 Offline Session Max for Offline Token
protected Boolean offlineSessionMaxLifespanEnabled;
protected Integer offlineSessionMaxLifespan;
protected Integer accessCodeLifespan;
protected Integer accessCodeLifespanUserAction;
protected Integer accessCodeLifespanLogin;
@ -296,6 +299,23 @@ public class RealmRepresentation {
this.offlineSessionIdleTimeout = offlineSessionIdleTimeout;
}
// KEYCLOAK-7688 Offline Session Max for Offline Token
public Boolean getOfflineSessionMaxLifespanEnabled() {
return offlineSessionMaxLifespanEnabled;
}
public void setOfflineSessionMaxLifespanEnabled(Boolean offlineSessionMaxLifespanEnabled) {
this.offlineSessionMaxLifespanEnabled = offlineSessionMaxLifespanEnabled;
}
public Integer getOfflineSessionMaxLifespan() {
return offlineSessionMaxLifespan;
}
public void setOfflineSessionMaxLifespan(Integer offlineSessionMaxLifespan) {
this.offlineSessionMaxLifespan = offlineSessionMaxLifespan;
}
public List<ScopeMappingRepresentation> getScopeMappings() {
return scopeMappings;
}

View file

@ -418,6 +418,31 @@ public class RealmAdapter implements CachedRealmModel {
updated.setOfflineSessionIdleTimeout(seconds);
}
// KEYCLOAK-7688 Offline Session Max for Offline Token
@Override
public boolean isOfflineSessionMaxLifespanEnabled() {
if (isUpdated()) return updated.isOfflineSessionMaxLifespanEnabled();
return cached.isOfflineSessionMaxLifespanEnabled();
}
@Override
public void setOfflineSessionMaxLifespanEnabled(boolean offlineSessionMaxLifespanEnabled) {
getDelegateForUpdate();
updated.setOfflineSessionMaxLifespanEnabled(offlineSessionMaxLifespanEnabled);
}
@Override
public int getOfflineSessionMaxLifespan() {
if (isUpdated()) return updated.getOfflineSessionMaxLifespan();
return cached.getOfflineSessionMaxLifespan();
}
@Override
public void setOfflineSessionMaxLifespan(int seconds) {
getDelegateForUpdate();
updated.setOfflineSessionMaxLifespan(seconds);
}
@Override
public int getAccessTokenLifespan() {
if (isUpdated()) return updated.getAccessTokenLifespan();

View file

@ -79,6 +79,9 @@ public class CachedRealm extends AbstractExtendableRevisioned {
protected int ssoSessionIdleTimeout;
protected int ssoSessionMaxLifespan;
protected int offlineSessionIdleTimeout;
// KEYCLOAK-7688 Offline Session Max for Offline Token
protected boolean offlineSessionMaxLifespanEnabled;
protected int offlineSessionMaxLifespan;
protected int accessTokenLifespan;
protected int accessTokenLifespanForImplicitFlow;
protected int accessCodeLifespan;
@ -181,6 +184,9 @@ public class CachedRealm extends AbstractExtendableRevisioned {
ssoSessionIdleTimeout = model.getSsoSessionIdleTimeout();
ssoSessionMaxLifespan = model.getSsoSessionMaxLifespan();
offlineSessionIdleTimeout = model.getOfflineSessionIdleTimeout();
// KEYCLOAK-7688 Offline Session Max for Offline Token
offlineSessionMaxLifespanEnabled = model.isOfflineSessionMaxLifespanEnabled();
offlineSessionMaxLifespan = model.getOfflineSessionMaxLifespan();
accessTokenLifespan = model.getAccessTokenLifespan();
accessTokenLifespanForImplicitFlow = model.getAccessTokenLifespanForImplicitFlow();
accessCodeLifespan = model.getAccessCodeLifespan();
@ -405,6 +411,15 @@ public class CachedRealm extends AbstractExtendableRevisioned {
return offlineSessionIdleTimeout;
}
// KEYCLOAK-7688 Offline Session Max for Offline Token
public boolean isOfflineSessionMaxLifespanEnabled() {
return offlineSessionMaxLifespanEnabled;
}
public int getOfflineSessionMaxLifespan() {
return offlineSessionMaxLifespan;
}
public int getAccessTokenLifespan() {
return accessTokenLifespan;
}

View file

@ -466,6 +466,27 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
realm.setOfflineSessionIdleTimeout(seconds);
}
// KEYCLOAK-7688 Offline Session Max for Offline Token
@Override
public boolean isOfflineSessionMaxLifespanEnabled() {
return getAttribute(RealmAttributes.OFFLINE_SESSION_MAX_LIFESPAN_ENABLED, false);
}
@Override
public void setOfflineSessionMaxLifespanEnabled(boolean offlineSessionMaxLifespanEnabled) {
setAttribute(RealmAttributes.OFFLINE_SESSION_MAX_LIFESPAN_ENABLED, offlineSessionMaxLifespanEnabled);
}
@Override
public int getOfflineSessionMaxLifespan() {
return getAttribute(RealmAttributes.OFFLINE_SESSION_MAX_LIFESPAN, Constants.DEFAULT_OFFLINE_SESSION_MAX_LIFESPAN);
}
@Override
public void setOfflineSessionMaxLifespan(int seconds) {
setAttribute(RealmAttributes.OFFLINE_SESSION_MAX_LIFESPAN, seconds);
}
@Override
public int getAccessCodeLifespan() {
return realm.getAccessCodeLifespan();

View file

@ -30,4 +30,8 @@ public interface RealmAttributes {
String ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN = "actionTokenGeneratedByUserLifespan";
// KEYCLOAK-7688 Offline Session Max for Offline Token
String OFFLINE_SESSION_MAX_LIFESPAN_ENABLED = "offlineSessionMaxLifespanEnabled";
String OFFLINE_SESSION_MAX_LIFESPAN = "offlineSessionMaxLifespan";
}

View file

@ -50,6 +50,9 @@ public interface Constants {
int DEFAULT_ACCESS_TOKEN_LIFESPAN_FOR_IMPLICIT_FLOW_TIMEOUT = 900;
// 30 days
int DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT = 2592000;
// KEYCLOAK-7688 Offline Session Max for Offline Token
// 60 days
int DEFAULT_OFFLINE_SESSION_MAX_LIFESPAN = 5184000;
String VERIFY_EMAIL_KEY = "VERIFY_EMAIL_KEY";
String VERIFY_EMAIL_CODE = "VERIFY_EMAIL_CODE";

View file

@ -265,6 +265,9 @@ public class ModelToRepresentation {
rep.setSsoSessionIdleTimeout(realm.getSsoSessionIdleTimeout());
rep.setSsoSessionMaxLifespan(realm.getSsoSessionMaxLifespan());
rep.setOfflineSessionIdleTimeout(realm.getOfflineSessionIdleTimeout());
// KEYCLOAK-7688 Offline Session Max for Offline Token
rep.setOfflineSessionMaxLifespanEnabled(realm.isOfflineSessionMaxLifespanEnabled());
rep.setOfflineSessionMaxLifespan(realm.getOfflineSessionMaxLifespan());
rep.setAccessCodeLifespan(realm.getAccessCodeLifespan());
rep.setAccessCodeLifespanUserAction(realm.getAccessCodeLifespanUserAction());
rep.setAccessCodeLifespanLogin(realm.getAccessCodeLifespanLogin());

View file

@ -196,6 +196,14 @@ public class RepresentationToModel {
newRealm.setOfflineSessionIdleTimeout(rep.getOfflineSessionIdleTimeout());
else newRealm.setOfflineSessionIdleTimeout(Constants.DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT);
// KEYCLOAK-7688 Offline Session Max for Offline Token
if (rep.getOfflineSessionMaxLifespanEnabled() != null) newRealm.setOfflineSessionMaxLifespanEnabled(rep.getOfflineSessionMaxLifespanEnabled());
else newRealm.setOfflineSessionMaxLifespanEnabled(false);
if (rep.getOfflineSessionMaxLifespan() != null)
newRealm.setOfflineSessionMaxLifespan(rep.getOfflineSessionMaxLifespan());
else newRealm.setOfflineSessionMaxLifespan(Constants.DEFAULT_OFFLINE_SESSION_MAX_LIFESPAN);
if (rep.getAccessCodeLifespan() != null) newRealm.setAccessCodeLifespan(rep.getAccessCodeLifespan());
else newRealm.setAccessCodeLifespan(60);
@ -906,6 +914,10 @@ public class RepresentationToModel {
if (rep.getSsoSessionMaxLifespan() != null) realm.setSsoSessionMaxLifespan(rep.getSsoSessionMaxLifespan());
if (rep.getOfflineSessionIdleTimeout() != null)
realm.setOfflineSessionIdleTimeout(rep.getOfflineSessionIdleTimeout());
// KEYCLOAK-7688 Offline Session Max for Offline Token
if (rep.getOfflineSessionMaxLifespanEnabled() != null) realm.setOfflineSessionMaxLifespanEnabled(rep.getOfflineSessionMaxLifespanEnabled());
if (rep.getOfflineSessionMaxLifespan() != null)
realm.setOfflineSessionMaxLifespan(rep.getOfflineSessionMaxLifespan());
if (rep.getRequiredCredentials() != null) {
realm.updateRequiredCredentials(rep.getRequiredCredentials());
}

View file

@ -179,6 +179,13 @@ public interface RealmModel extends RoleContainerModel {
int getAccessTokenLifespan();
// KEYCLOAK-7688 Offline Session Max for Offline Token
boolean isOfflineSessionMaxLifespanEnabled();
void setOfflineSessionMaxLifespanEnabled(boolean offlineSessionMaxLifespanEnabled);
int getOfflineSessionMaxLifespan();
void setOfflineSessionMaxLifespan(int seconds);
void setAccessTokenLifespan(int seconds);
int getAccessTokenLifespanForImplicitFlow();

View file

@ -76,6 +76,9 @@ public class ApplianceBootstrap {
realm.setAccessTokenLifespanForImplicitFlow(Constants.DEFAULT_ACCESS_TOKEN_LIFESPAN_FOR_IMPLICIT_FLOW_TIMEOUT);
realm.setSsoSessionMaxLifespan(36000);
realm.setOfflineSessionIdleTimeout(Constants.DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT);
// KEYCLOAK-7688 Offline Session Max for Offline Token
realm.setOfflineSessionMaxLifespanEnabled(false);
realm.setOfflineSessionMaxLifespan(Constants.DEFAULT_OFFLINE_SESSION_MAX_LIFESPAN);
realm.setAccessCodeLifespan(60);
realm.setAccessCodeLifespanUserAction(300);
realm.setAccessCodeLifespanLogin(1800);

View file

@ -118,11 +118,16 @@ public class AuthenticationManager {
return false;
}
int currentTime = Time.currentTime();
// 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.getOfflineSessionIdleTimeout() + SessionTimeoutHelper.IDLE_TIMEOUT_WINDOW_SECONDS;
return userSession.getLastSessionRefresh() + maxIdle > currentTime;
// KEYCLOAK-7688 Offline Session Max for Offline Token
if (realm.isOfflineSessionMaxLifespanEnabled()) {
int max = userSession.getStarted() + realm.getOfflineSessionMaxLifespan();
return userSession.getLastSessionRefresh() + maxIdle > currentTime && max > currentTime;
} else {
return userSession.getLastSessionRefresh() + maxIdle > currentTime;
}
}
public static void expireUserSessionCookie(KeycloakSession session, UserSessionModel userSession, RealmModel realm, UriInfo uriInfo, HttpHeaders headers, ClientConnection connection) {

View file

@ -30,11 +30,13 @@ import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.RoleResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.Time;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.Constants;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.SessionTimeoutHelper;
import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.RefreshToken;
@ -661,4 +663,88 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
assertNotEquals(offlineToken.getSessionState(), offlineToken2.getSessionState());
}
// KEYCLOAK-7688 Offline Session Max for Offline Token
private int[] changeOfflineSessionSettings(boolean isEnabled, int sessionMax, int sessionIdle) {
int prev[] = new int[2];
RealmRepresentation rep = adminClient.realm("test").toRepresentation();
prev[0] = rep.getOfflineSessionMaxLifespan().intValue();
prev[1] = rep.getOfflineSessionIdleTimeout().intValue();
RealmBuilder realmBuilder = RealmBuilder.create();
realmBuilder.offlineSessionMaxLifespanEnabled(isEnabled).offlineSessionMaxLifespan(sessionMax).offlineSessionIdleTimeout(sessionIdle);
adminClient.realm("test").update(realmBuilder.build());
return prev;
}
@Test
public void offlineTokenBrowserFlowMaxLifespanExpired() throws Exception {
// expect that offline session expired by max lifespan
final int MAX_LIFESPAN = 3600;
final int IDLE_LIFESPAN = 6000;
testOfflineSessionExpiration(IDLE_LIFESPAN, MAX_LIFESPAN, MAX_LIFESPAN + 60);
}
@Test
public void offlineTokenBrowserFlowIdleTimeExpired() throws Exception {
// expect that offline session expired by idle time
final int MAX_LIFESPAN = 3000;
final int IDLE_LIFESPAN = 600;
// Additional time window is added for the case when session was updated in different DC and the update to current DC was postponed
testOfflineSessionExpiration(IDLE_LIFESPAN, MAX_LIFESPAN, IDLE_LIFESPAN + SessionTimeoutHelper.IDLE_TIMEOUT_WINDOW_SECONDS + 60);
}
private void testOfflineSessionExpiration(int idleTime, int maxLifespan, int offset) {
int prev[] = null;
try {
prev = changeOfflineSessionSettings(true, maxLifespan, idleTime);
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.clientId("offline-client");
oauth.redirectUri(offlineClientAppUri);
oauth.doLogin("test-user@localhost", "password");
EventRepresentation loginEvent = events.expectLogin()
.client("offline-client")
.detail(Details.REDIRECT_URI, offlineClientAppUri)
.assertEvent();
final String sessionId = loginEvent.getSessionId();
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "secret1");
String offlineTokenString = tokenResponse.getRefreshToken();
RefreshToken offlineToken = oauth.verifyRefreshToken(offlineTokenString);
assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType());
tokenResponse = oauth.doRefreshTokenRequest(offlineTokenString, "secret1");
AccessToken refreshedToken = oauth.verifyToken(tokenResponse.getAccessToken());
offlineTokenString = tokenResponse.getRefreshToken();
offlineToken = oauth.verifyRefreshToken(offlineTokenString);
Assert.assertEquals(200, tokenResponse.getStatusCode());
Assert.assertEquals(sessionId, refreshedToken.getSessionState());
// wait to expire
setTimeOffset(offset);
tokenResponse = oauth.doRefreshTokenRequest(offlineTokenString, "secret1");
Assert.assertEquals(400, tokenResponse.getStatusCode());
assertEquals("invalid_grant", tokenResponse.getError());
// Assert userSession expired
testingClient.testing().removeExpired("test");
try {
testingClient.testing().removeUserSession("test", sessionId);
} catch (NotFoundException nfe) {
// Ignore
}
setTimeOffset(0);
} finally {
changeOfflineSessionSettings(false, prev[0], prev[1]);
}
}
}

View file

@ -225,4 +225,20 @@ public class RealmBuilder {
rep.getGroups().add(group);
return this;
}
// KEYCLOAK-7688 Offline Session Max for Offline Token
public RealmBuilder offlineSessionIdleTimeout(int offlineSessionIdleTimeout) {
rep.setOfflineSessionIdleTimeout(offlineSessionIdleTimeout);
return this;
}
public RealmBuilder offlineSessionMaxLifespan(int offlineSessionMaxLifespan) {
rep.setOfflineSessionMaxLifespan(offlineSessionMaxLifespan);
return this;
}
public RealmBuilder offlineSessionMaxLifespanEnabled(boolean offlineSessionMaxLifespanEnabled) {
rep.setOfflineSessionMaxLifespanEnabled(offlineSessionMaxLifespanEnabled);
return this;
}
}

View file

@ -108,6 +108,13 @@ sso-session-idle.tooltip=Time a session is allowed to be idle before it expires.
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.
## KEYCLOAK-7688 Offline Session Max for Offline Token
offline-session-max-limited=Offline Session Max Limited
offline-session-max-limited.tooltip=Enable Offline Session Max.
offline-session-max=Offline Session Max
offline-session-max.tooltip=Max time before an offline session is expired regardless of activity.
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.
access-token-lifespan-for-implicit-flow=Access Token Lifespan For Implicit Flow

View file

@ -1090,6 +1090,8 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http,
$scope.realm.ssoSessionIdleTimeout = TimeUnit2.asUnit(realm.ssoSessionIdleTimeout);
$scope.realm.ssoSessionMaxLifespan = TimeUnit2.asUnit(realm.ssoSessionMaxLifespan);
$scope.realm.offlineSessionIdleTimeout = TimeUnit2.asUnit(realm.offlineSessionIdleTimeout);
// KEYCLOAK-7688 Offline Session Max for Offline Token
$scope.realm.offlineSessionMaxLifespan = TimeUnit2.asUnit(realm.offlineSessionMaxLifespan);
$scope.realm.accessCodeLifespan = TimeUnit2.asUnit(realm.accessCodeLifespan);
$scope.realm.accessCodeLifespanLogin = TimeUnit2.asUnit(realm.accessCodeLifespanLogin);
$scope.realm.accessCodeLifespanUserAction = TimeUnit2.asUnit(realm.accessCodeLifespanUserAction);
@ -1137,6 +1139,8 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http,
$scope.realm.ssoSessionIdleTimeout = $scope.realm.ssoSessionIdleTimeout.toSeconds();
$scope.realm.ssoSessionMaxLifespan = $scope.realm.ssoSessionMaxLifespan.toSeconds();
$scope.realm.offlineSessionIdleTimeout = $scope.realm.offlineSessionIdleTimeout.toSeconds();
// KEYCLOAK-7688 Offline Session Max for Offline Token
$scope.realm.offlineSessionMaxLifespan = $scope.realm.offlineSessionMaxLifespan.toSeconds();
$scope.realm.accessCodeLifespan = $scope.realm.accessCodeLifespan.toSeconds();
$scope.realm.accessCodeLifespanUserAction = $scope.realm.accessCodeLifespanUserAction.toSeconds();
$scope.realm.accessCodeLifespanLogin = $scope.realm.accessCodeLifespanLogin.toSeconds();

View file

@ -74,6 +74,32 @@
<kc-tooltip>{{:: 'offline-session-idle.tooltip' | translate}}</kc-tooltip>
</div>
<!-- KEYCLOAK-7688 Offline Session Max for Offline Token -->
<div class="form-group">
<label class="col-md-2 control-label" for="offlineSessionMaxLifespanEnabled">{{:: 'offline-session-max-limited' | translate}}</label>
<div class="col-md-3">
<input ng-change="" ng-model="realm.offlineSessionMaxLifespanEnabled"
name="offlineSessionMaxLifespanEnabled"
id="offlineSessionMaxLifespanEnabled"
onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
</div>
<kc-tooltip>{{:: 'offline-session-max-limited.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group" data-ng-show="realm.offlineSessionMaxLifespanEnabled">
<label class="col-md-2 control-label" for="offlineSessionMaxLifespan">{{:: 'offline-session-max' | translate}}</label>
<div class="col-md-6 time-selector">
<input class="form-control" type="number" required min="1"
max="31536000" data-ng-model="realm.offlineSessionMaxLifespan.time"
id="offlineSessionMaxLifespan" name="offlineSessionMaxLifespan"/>
<select class="form-control" name="offlineSessionMaxLifespanUnit" data-ng-model="realm.offlineSessionMaxLifespan.unit">
<option value="Minutes">{{:: 'minutes' | translate}}</option>
<option value="Hours">{{:: 'hours' | translate}}</option>
<option value="Days">{{:: 'days' | translate}}</option>
</select>
</div>
<kc-tooltip>{{:: 'offline-session-max.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="accessTokenLifespan">{{:: 'access-token-lifespan' | translate}}</label>