From e582de2837a4172c5774d4f1c5a3c9373493e43d Mon Sep 17 00:00:00 2001 From: Stian Thorgersen Date: Wed, 14 Oct 2015 20:16:26 +0200 Subject: [PATCH] KEYCLOAK-1961 Same token can be used multiple times to obtain access token --- .../META-INF/jpa-changelog-1.6.0.xml | 6 ++ .../idm/RealmRepresentation.java | 9 ++ .../modules/MigrationFromOlderVersions.xml | 13 +++ .../messages/admin-messages_en.properties | 2 + .../resources/partials/realm-tokens.html | 11 +++ .../java/org/keycloak/models/RealmModel.java | 3 + .../keycloak/models/entities/RealmEntity.java | 9 ++ .../models/utils/ModelToRepresentation.java | 1 + .../models/utils/RepresentationToModel.java | 8 +- .../models/file/adapter/RealmAdapter.java | 10 +++ .../models/cache/infinispan/RealmAdapter.java | 12 +++ .../models/cache/entities/CachedRealm.java | 6 ++ .../org/keycloak/models/jpa/RealmAdapter.java | 10 +++ .../models/jpa/entities/RealmEntity.java | 10 +++ .../mongo/keycloak/adapters/RealmAdapter.java | 10 +++ .../keycloak/protocol/oidc/TokenManager.java | 9 ++ .../testsuite/oauth/RefreshTokenTest.java | 88 +++++++++++++++++++ 17 files changed, 213 insertions(+), 4 deletions(-) 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 7c5de94d33..9e48a6aa08 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 @@ -48,5 +48,11 @@ + + + + + + \ No newline at end of file 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 c1c53b32f0..1bec10b7cf 100755 --- a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java @@ -10,6 +10,7 @@ public class RealmRepresentation { protected String id; protected String realm; protected Integer notBefore; + protected Boolean revokeRefreshToken; protected Integer accessTokenLifespan; protected Integer ssoSessionIdleTimeout; protected Integer ssoSessionMaxLifespan; @@ -166,6 +167,14 @@ public class RealmRepresentation { this.sslRequired = sslRequired; } + public Boolean getRevokeRefreshToken() { + return revokeRefreshToken; + } + + public void setRevokeRefreshToken(Boolean revokeRefreshToken) { + this.revokeRefreshToken = revokeRefreshToken; + } + public Integer getAccessTokenLifespan() { return accessTokenLifespan; } diff --git a/docbook/auth-server-docs/reference/en/en-US/modules/MigrationFromOlderVersions.xml b/docbook/auth-server-docs/reference/en/en-US/modules/MigrationFromOlderVersions.xml index 10027b90ac..25a393fd19 100755 --- a/docbook/auth-server-docs/reference/en/en-US/modules/MigrationFromOlderVersions.xml +++ b/docbook/auth-server-docs/reference/en/en-US/modules/MigrationFromOlderVersions.xml @@ -79,6 +79,19 @@
Version specific migration +
+ Migrating to 1.6.0.Final + + Refresh tokens are not reusable anymore + + Old versions of Keycloak allowed reusing refresh tokens multiple times. Keycloak no longer permits + this by default. When a refresh token is used to obtain a new access token a new refresh token is also + included. This new refresh token should be used next time the access token is refreshed. If this is + a problem for you it's possible to enable reuse of refresh tokens in the admin console under token + settings. + + +
Migrating to 1.5.0.Final 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 3e653dbf72..802645e583 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 @@ -66,6 +66,8 @@ realm-cache-enabled=Realm Cache Enabled realm-cache-enabled.tooltip=Enable/disable cache for realm, client and role data. user-cache-enabled=User Cache Enabled user-cache-enabled.tooltip=Enable/disable user and user role mapping cache. +revoke-refresh-token=Revoke Refresh Token +revoke-refresh-token.tooltip=If enabled refresh tokens can only be used once. Otherwise refresh tokens are not revoked when used and can be used multiple times. sso-session-idle=SSO Session Idle seconds=Seconds minutes=Minutes 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 a44b939b99..bb5502223d 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 @@ -3,6 +3,17 @@
+
+ + +
+ +
+ + {{:: 'revoke-refresh-token.tooltip' | translate}} + +
+
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 452e2b35d9..7471a4c2b1 100755 --- a/model/api/src/main/java/org/keycloak/models/RealmModel.java +++ b/model/api/src/main/java/org/keycloak/models/RealmModel.java @@ -91,6 +91,9 @@ public interface RealmModel extends RoleContainerModel { void setResetPasswordAllowed(boolean resetPasswordAllowed); + boolean isRevokeRefreshToken(); + void setRevokeRefreshToken(boolean revokeRefreshToken); + int getSsoSessionIdleTimeout(); void setSsoSessionIdleTimeout(int seconds); 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 3dc43efd32..e5fe6d272f 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 @@ -39,6 +39,7 @@ public class RealmEntity extends AbstractIdentifiableEntity { private int failureFactor; //--- end brute force settings + private boolean revokeRefreshToken; private int ssoSessionIdleTimeout; private int ssoSessionMaxLifespan; private int accessTokenLifespan; @@ -229,6 +230,14 @@ public class RealmEntity extends AbstractIdentifiableEntity { this.failureFactor = failureFactor; } + public boolean isRevokeRefreshToken() { + return revokeRefreshToken; + } + + public void setRevokeRefreshToken(boolean revokeRefreshToken) { + this.revokeRefreshToken = revokeRefreshToken; + } + public int getSsoSessionIdleTimeout() { return ssoSessionIdleTimeout; } 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 03e88a8eb6..6b4a6be2cc 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 @@ -144,6 +144,7 @@ public class ModelToRepresentation { rep.setVerifyEmail(realm.isVerifyEmail()); rep.setResetPasswordAllowed(realm.isResetPasswordAllowed()); rep.setEditUsernameAllowed(realm.isEditUsernameAllowed()); + rep.setRevokeRefreshToken(realm.isRevokeRefreshToken()); rep.setAccessTokenLifespan(realm.getAccessTokenLifespan()); rep.setSsoSessionIdleTimeout(realm.getSsoSessionIdleTimeout()); rep.setSsoSessionMaxLifespan(realm.getSsoSessionMaxLifespan()); 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 839e149c07..6c61e663f3 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,9 +1,5 @@ package org.keycloak.models.utils; -import org.keycloak.models.session.PersistentClientSessionModel; -import org.keycloak.models.session.PersistentUserSessionModel; -import org.keycloak.representations.idm.OfflineClientSessionRepresentation; -import org.keycloak.representations.idm.OfflineUserSessionRepresentation; import org.keycloak.util.Base64; import org.jboss.logging.Logger; import org.keycloak.enums.SslRequired; @@ -100,6 +96,9 @@ public class RepresentationToModel { if (rep.getNotBefore() != null) newRealm.setNotBefore(rep.getNotBefore()); + if (rep.getRevokeRefreshToken() != null) newRealm.setRevokeRefreshToken(rep.getRevokeRefreshToken()); + else newRealm.setRevokeRefreshToken(false); + if (rep.getAccessTokenLifespan() != null) newRealm.setAccessTokenLifespan(rep.getAccessTokenLifespan()); else newRealm.setAccessTokenLifespan(300); @@ -532,6 +531,7 @@ public class RepresentationToModel { if (rep.getAccessCodeLifespanUserAction() != null) realm.setAccessCodeLifespanUserAction(rep.getAccessCodeLifespanUserAction()); if (rep.getAccessCodeLifespanLogin() != null) realm.setAccessCodeLifespanLogin(rep.getAccessCodeLifespanLogin()); if (rep.getNotBefore() != null) realm.setNotBefore(rep.getNotBefore()); + if (rep.getRevokeRefreshToken() != null) realm.setRevokeRefreshToken(rep.getRevokeRefreshToken()); if (rep.getAccessTokenLifespan() != null) realm.setAccessTokenLifespan(rep.getAccessTokenLifespan()); if (rep.getSsoSessionIdleTimeout() != null) realm.setSsoSessionIdleTimeout(rep.getSsoSessionIdleTimeout()); if (rep.getSsoSessionMaxLifespan() != null) realm.setSsoSessionMaxLifespan(rep.getSsoSessionMaxLifespan()); 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 5a62223970..26227b1ee4 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 @@ -325,6 +325,16 @@ public class RealmAdapter implements RealmModel { } + @Override + public boolean isRevokeRefreshToken() { + return realm.isRevokeRefreshToken(); + } + + @Override + public void setRevokeRefreshToken(boolean revokeRefreshToken) { + realm.setRevokeRefreshToken(revokeRefreshToken); + } + @Override public int getSsoSessionIdleTimeout() { return realm.getSsoSessionIdleTimeout(); 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 54700e23f0..51d445c890 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 @@ -239,6 +239,18 @@ public class RealmAdapter implements RealmModel { updated.setEditUsernameAllowed(editUsernameAllowed); } + @Override + public boolean isRevokeRefreshToken() { + if (updated != null) return updated.isRevokeRefreshToken(); + return cached.isRevokeRefreshToken(); + } + + @Override + public void setRevokeRefreshToken(boolean revokeRefreshToken) { + getDelegateForUpdate(); + updated.setRevokeRefreshToken(revokeRefreshToken); + } + @Override public int getSsoSessionIdleTimeout() { if (updated != null) return updated.getSsoSessionIdleTimeout(); 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 27bab7468d..5193588d44 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 @@ -55,6 +55,7 @@ public class CachedRealm implements Serializable { private int failureFactor; //--- end brute force settings + private boolean revokeRefreshToken; private int ssoSessionIdleTimeout; private int ssoSessionMaxLifespan; private int accessTokenLifespan; @@ -136,6 +137,7 @@ public class CachedRealm implements Serializable { failureFactor = model.getFailureFactor(); //--- end brute force settings + revokeRefreshToken = model.isRevokeRefreshToken(); ssoSessionIdleTimeout = model.getSsoSessionIdleTimeout(); ssoSessionMaxLifespan = model.getSsoSessionMaxLifespan(); accessTokenLifespan = model.getAccessTokenLifespan(); @@ -313,6 +315,10 @@ public class CachedRealm implements Serializable { return editUsernameAllowed; } + public boolean isRevokeRefreshToken() { + return revokeRefreshToken; + } + public int getSsoSessionIdleTimeout() { return ssoSessionIdleTimeout; } 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 6268735339..2c8e2ad9fc 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 @@ -336,6 +336,16 @@ public class RealmAdapter implements RealmModel { realm.setNotBefore(notBefore); } + @Override + public boolean isRevokeRefreshToken() { + return realm.isRevokeRefreshToken(); + } + + @Override + public void setRevokeRefreshToken(boolean revokeRefreshToken) { + realm.setRevokeRefreshToken(revokeRefreshToken); + } + @Override public int getAccessTokenLifespan() { return realm.getAccessTokenLifespan(); 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 fbfb700b24..27c4824f94 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 @@ -76,6 +76,8 @@ public class RealmEntity { @Column(name="EDIT_USERNAME_ALLOWED") protected boolean editUsernameAllowed; + @Column(name="REVOKE_REFRESH_TOKEN") + private boolean revokeRefreshToken; @Column(name="SSO_IDLE_TIMEOUT") private int ssoSessionIdleTimeout; @Column(name="SSO_MAX_LIFESPAN") @@ -288,6 +290,14 @@ public class RealmEntity { this.editUsernameAllowed = editUsernameAllowed; } + public boolean isRevokeRefreshToken() { + return revokeRefreshToken; + } + + public void setRevokeRefreshToken(boolean revokeRefreshToken) { + this.revokeRefreshToken = revokeRefreshToken; + } + public int getSsoSessionIdleTimeout() { return ssoSessionIdleTimeout; } 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 a6166cd5e2..05ae8bcd4c 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 @@ -311,6 +311,16 @@ public class RealmAdapter extends AbstractMongoAdapter impleme updateRealm(); } + @Override + public boolean isRevokeRefreshToken() { + return realm.isRevokeRefreshToken(); + } + + @Override + public void setRevokeRefreshToken(boolean revokeRefreshToken) { + realm.setRevokeRefreshToken(revokeRefreshToken); + updateRealm(); + } @Override public int getSsoSessionIdleTimeout() { 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 e899186c81..0888dab878 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -161,6 +161,15 @@ public class TokenManager { } int currentTime = Time.currentTime(); + + if (realm.isRevokeRefreshToken() && !refreshToken.getType().equals(TokenUtil.TOKEN_TYPE_OFFLINE)) { + if (refreshToken.getIssuedAt() < validation.clientSession.getTimestamp()) { + throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale token"); + } + + validation.clientSession.setTimestamp(currentTime); + } + validation.userSession.setLastSessionRefresh(currentTime); AccessTokenResponseBuilder responseBuilder = responseBuilder(realm, authorizedClient, event, session, validation.userSession, validation.clientSession) diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java index 363a1e945b..742f5d6703 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java @@ -30,6 +30,7 @@ import org.keycloak.enums.SslRequired; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.Event; +import org.keycloak.events.EventType; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserSessionModel; @@ -181,6 +182,93 @@ public class RefreshTokenTest { Time.setOffset(0); } + @Test + public void refreshTokenReuseTokenWithoutRefreshTokensRevoked() throws Exception { + try { + oauth.doLogin("test-user@localhost", "password"); + + Event loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + AccessTokenResponse response1 = oauth.doAccessTokenRequest(code, "password"); + RefreshToken refreshToken1 = oauth.verifyRefreshToken(response1.getRefreshToken()); + + events.expectCodeToToken(codeId, sessionId).assertEvent(); + + Time.setOffset(2); + + AccessTokenResponse response2 = oauth.doRefreshTokenRequest(response1.getRefreshToken(), "password"); + Assert.assertEquals(200, response2.getStatusCode()); + + events.expectRefresh(refreshToken1.getId(), sessionId).assertEvent(); + + AccessTokenResponse response3 = oauth.doRefreshTokenRequest(response1.getRefreshToken(), "password"); + + Assert.assertEquals(200, response3.getStatusCode()); + + events.expectRefresh(refreshToken1.getId(), sessionId).assertEvent(); + } finally { + Time.setOffset(0); + } + } + + @Test + public void refreshTokenReuseTokenWithRefreshTokensRevoked() throws Exception { + try { + keycloakRule.configure(new KeycloakRule.KeycloakSetup() { + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + appRealm.setRevokeRefreshToken(true); + } + }); + + oauth.doLogin("test-user@localhost", "password"); + + Event loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + + AccessTokenResponse response1 = oauth.doAccessTokenRequest(code, "password"); + RefreshToken refreshToken1 = oauth.verifyRefreshToken(response1.getRefreshToken()); + + events.expectCodeToToken(codeId, sessionId).assertEvent(); + + Time.setOffset(2); + + AccessTokenResponse response2 = oauth.doRefreshTokenRequest(response1.getRefreshToken(), "password"); + RefreshToken refreshToken2 = oauth.verifyRefreshToken(response2.getRefreshToken()); + + Assert.assertEquals(200, response2.getStatusCode()); + + events.expectRefresh(refreshToken1.getId(), sessionId).assertEvent(); + + AccessTokenResponse response3 = oauth.doRefreshTokenRequest(response1.getRefreshToken(), "password"); + + Assert.assertEquals(400, response3.getStatusCode()); + + events.expectRefresh(refreshToken1.getId(), sessionId).removeDetail(Details.TOKEN_ID).removeDetail(Details.UPDATED_REFRESH_TOKEN_ID).error("invalid_token").assertEvent(); + + oauth.doRefreshTokenRequest(response2.getRefreshToken(), "password"); + + events.expectRefresh(refreshToken2.getId(), sessionId).assertEvent(); + } finally { + Time.setOffset(0); + keycloakRule.configure(new KeycloakRule.KeycloakSetup() { + @Override + public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) { + appRealm.setRevokeRefreshToken(false); + } + }); + } + } + PrivateKey privateKey; PublicKey publicKey;