From b8262a9f0275758ce2d1b4a05c350a465580b24a Mon Sep 17 00:00:00 2001 From: Hynek Mlnarik Date: Fri, 5 May 2017 01:20:58 +0200 Subject: [PATCH] KEYCLOAK-4628 Single-use cache + its functionality incorporated into reset password token. Utilize single-use cache for relevant actions in execute-actions token --- .../idm/RealmRepresentation.java | 18 +++ .../demo-dist/src/main/xslt/standalone.xsl | 1 + .../src/main/cli/keycloak-install-base.cli | 3 + .../src/main/cli/keycloak-install-ha-base.cli | 3 + ...ltInfinispanConnectionProviderFactory.java | 18 +++ .../InfinispanConnectionProvider.java | 5 + .../AddInvalidatedActionTokenEvent.java | 50 +++++++ .../models/cache/infinispan/RealmAdapter.java | 24 ++++ .../RemoveActionTokensSpecificEvent.java | 42 ++++++ .../infinispan/entities/CachedRealm.java | 12 ++ .../InfinispanActionTokenStoreProvider.java | 98 +++++++++++++ ...nispanActionTokenStoreProviderFactory.java | 100 +++++++++++++ ...nAuthenticationSessionProviderFactory.java | 2 + .../InfinispanKeycloakTransaction.java | 136 ++++++++++-------- .../entities/ActionTokenReducedKey.java | 104 ++++++++++++++ .../entities/ActionTokenValueEntity.java | 76 ++++++++++ ...oak.models.ActionTokenStoreProviderFactory | 1 + .../org/keycloak/models/jpa/RealmAdapter.java | 20 +++ .../models/jpa/entities/RealmAttributes.java | 4 + .../authentication/RequiredActionFactory.java | 9 ++ .../models/ActionTokenStoreProvider.java | 54 +++++++ .../ActionTokenStoreProviderFactory.java | 27 ++++ .../keycloak/models/ActionTokenStoreSpi.java | 50 +++++++ .../models/utils/ModelToRepresentation.java | 2 + .../models/utils/RepresentationToModel.java | 12 ++ .../AuthenticationSessionProviderFactory.java | 2 - .../services/org.keycloak.provider.Spi | 1 + .../keycloak/models/ActionTokenKeyModel.java | 46 ++++++ .../models/ActionTokenValueModel.java | 39 +++++ .../java/org/keycloak/models/RealmModel.java | 6 + .../AbstractActionTokenHander.java | 17 ++- .../actiontoken/ActionTokenHandler.java | 19 ++- .../actiontoken/DefaultActionToken.java | 39 +++-- .../actiontoken/DefaultActionTokenKey.java | 41 +++++- .../ExecuteActionsActionToken.java | 18 +-- .../ExecuteActionsActionTokenHandler.java | 27 +++- .../IdpVerifyAccountLinkActionToken.java | 8 +- .../ResetCredentialsActionToken.java | 21 +-- .../ResetCredentialsActionTokenHandler.java | 10 +- .../verifyemail/VerifyEmailActionToken.java | 8 +- .../IdpEmailVerificationAuthenticator.java | 4 +- .../resetcred/ResetCredentialEmail.java | 9 +- .../requiredactions/UpdatePassword.java | 5 + .../requiredactions/UpdateTotp.java | 5 + .../requiredactions/VerifyEmail.java | 15 +- .../managers/AuthenticationManager.java | 33 ++--- .../resources/LoginActionsService.java | 6 +- .../resources/LoginActionsServiceChecks.java | 8 ++ .../resources/admin/UsersResource.java | 16 ++- .../RequiredActionEmailVerificationTest.java | 2 - .../keycloak/testsuite/admin/UserTest.java | 61 +++++++- .../testsuite/admin/realm/RealmTest.java | 10 ++ .../testsuite/forms/ResetPasswordTest.java | 30 ++-- .../test/resources/admin-test/testrealm.json | 2 + .../messages/admin-messages_en.properties | 6 + .../admin/resources/js/controllers/realm.js | 4 + .../admin/resources/js/controllers/users.js | 5 +- .../theme/base/admin/resources/js/services.js | 3 +- .../resources/partials/realm-tokens.html | 34 +++++ .../resources/partials/user-credentials.html | 14 ++ .../KeycloakServerDeploymentProcessor.java | 4 +- .../keycloak-infinispan.xml | 8 ++ .../keycloak-infinispan2.xml | 8 ++ 63 files changed, 1243 insertions(+), 222 deletions(-) create mode 100644 model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/AddInvalidatedActionTokenEvent.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RemoveActionTokensSpecificEvent.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanActionTokenStoreProvider.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanActionTokenStoreProviderFactory.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ActionTokenReducedKey.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ActionTokenValueEntity.java create mode 100644 model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.ActionTokenStoreProviderFactory create mode 100644 server-spi-private/src/main/java/org/keycloak/models/ActionTokenStoreProvider.java create mode 100644 server-spi-private/src/main/java/org/keycloak/models/ActionTokenStoreProviderFactory.java create mode 100644 server-spi-private/src/main/java/org/keycloak/models/ActionTokenStoreSpi.java create mode 100644 server-spi/src/main/java/org/keycloak/models/ActionTokenKeyModel.java create mode 100644 server-spi/src/main/java/org/keycloak/models/ActionTokenValueModel.java 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 b14e55bda1..670e1d8bde 100755 --- a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java @@ -46,6 +46,8 @@ public class RealmRepresentation { protected Integer accessCodeLifespan; protected Integer accessCodeLifespanUserAction; protected Integer accessCodeLifespanLogin; + protected Integer actionTokenGeneratedByAdminLifespan; + protected Integer actionTokenGeneratedByUserLifespan; protected Boolean enabled; protected String sslRequired; @Deprecated @@ -338,6 +340,22 @@ public class RealmRepresentation { this.accessCodeLifespanLogin = accessCodeLifespanLogin; } + public Integer getActionTokenGeneratedByAdminLifespan() { + return actionTokenGeneratedByAdminLifespan; + } + + public void setActionTokenGeneratedByAdminLifespan(Integer actionTokenGeneratedByAdminLifespan) { + this.actionTokenGeneratedByAdminLifespan = actionTokenGeneratedByAdminLifespan; + } + + public Integer getActionTokenGeneratedByUserLifespan() { + return actionTokenGeneratedByUserLifespan; + } + + public void setActionTokenGeneratedByUserLifespan(Integer actionTokenGeneratedByUserLifespan) { + this.actionTokenGeneratedByUserLifespan = actionTokenGeneratedByUserLifespan; + } + public List getDefaultRoles() { return defaultRoles; } diff --git a/distribution/demo-dist/src/main/xslt/standalone.xsl b/distribution/demo-dist/src/main/xslt/standalone.xsl index 7c0b3c1387..d78ff753e7 100755 --- a/distribution/demo-dist/src/main/xslt/standalone.xsl +++ b/distribution/demo-dist/src/main/xslt/standalone.xsl @@ -93,6 +93,7 @@ + diff --git a/distribution/server-overlay/src/main/cli/keycloak-install-base.cli b/distribution/server-overlay/src/main/cli/keycloak-install-base.cli index b8a7e89eb6..5ce3122fa8 100644 --- a/distribution/server-overlay/src/main/cli/keycloak-install-base.cli +++ b/distribution/server-overlay/src/main/cli/keycloak-install-base.cli @@ -15,4 +15,7 @@ embed-server --server-config=standalone.xml /subsystem=infinispan/cache-container=keycloak/local-cache=keys:add() /subsystem=infinispan/cache-container=keycloak/local-cache=keys/eviction=EVICTION:add(max-entries=1000,strategy=LRU) /subsystem=infinispan/cache-container=keycloak/local-cache=keys/expiration=EXPIRATION:add(max-idle=3600000) +/subsystem=infinispan/cache-container=keycloak/local-cache=actionTokens:add() +/subsystem=infinispan/cache-container=keycloak/local-cache=actionTokens/eviction=EVICTION:add(max-entries=-1,strategy=NONE) +/subsystem=infinispan/cache-container=keycloak/local-cache=actionTokens/expiration=EXPIRATION:add(max-idle=-1,interval=300000) /extension=org.keycloak.keycloak-server-subsystem/:add(module=org.keycloak.keycloak-server-subsystem) diff --git a/distribution/server-overlay/src/main/cli/keycloak-install-ha-base.cli b/distribution/server-overlay/src/main/cli/keycloak-install-ha-base.cli index e092374611..4710eb8a82 100644 --- a/distribution/server-overlay/src/main/cli/keycloak-install-ha-base.cli +++ b/distribution/server-overlay/src/main/cli/keycloak-install-ha-base.cli @@ -16,4 +16,7 @@ embed-server --server-config=standalone-ha.xml /subsystem=infinispan/cache-container=keycloak/local-cache=keys:add() /subsystem=infinispan/cache-container=keycloak/local-cache=keys/eviction=EVICTION:add(max-entries=1000,strategy=LRU) /subsystem=infinispan/cache-container=keycloak/local-cache=keys/expiration=EXPIRATION:add(max-idle=3600000) +/subsystem=infinispan/cache-container=keycloak/local-cache=actionTokens:add() +/subsystem=infinispan/cache-container=keycloak/local-cache=actionTokens/eviction=EVICTION:add(max-entries=-1,strategy=NONE) +/subsystem=infinispan/cache-container=keycloak/local-cache=actionTokens/expiration=EXPIRATION:add(max-idle=-1,interval=300000) /extension=org.keycloak.keycloak-server-subsystem/:add(module=org.keycloak.keycloak-server-subsystem) diff --git a/model/infinispan/src/main/java/org/keycloak/connections/infinispan/DefaultInfinispanConnectionProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/connections/infinispan/DefaultInfinispanConnectionProviderFactory.java index 0f63129aae..4bd78017ea 100755 --- a/model/infinispan/src/main/java/org/keycloak/connections/infinispan/DefaultInfinispanConnectionProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/connections/infinispan/DefaultInfinispanConnectionProviderFactory.java @@ -120,6 +120,7 @@ public class DefaultInfinispanConnectionProviderFactory implements InfinispanCon cacheManager.getCache(InfinispanConnectionProvider.AUTHORIZATION_CACHE_NAME, true); cacheManager.getCache(InfinispanConnectionProvider.AUTHENTICATION_SESSIONS_CACHE_NAME, true); cacheManager.getCache(InfinispanConnectionProvider.KEYS_CACHE_NAME, true); + cacheManager.getCache(InfinispanConnectionProvider.ACTION_TOKEN_CACHE, true); logger.debugv("Using container managed Infinispan cache container, lookup={1}", cacheContainerLookup); } catch (Exception e) { @@ -220,6 +221,9 @@ public class DefaultInfinispanConnectionProviderFactory implements InfinispanCon cacheManager.defineConfiguration(InfinispanConnectionProvider.KEYS_CACHE_NAME, getKeysCacheConfig()); cacheManager.getCache(InfinispanConnectionProvider.KEYS_CACHE_NAME, true); + + cacheManager.defineConfiguration(InfinispanConnectionProvider.ACTION_TOKEN_CACHE, getActionTokenCacheConfig()); + cacheManager.getCache(InfinispanConnectionProvider.ACTION_TOKEN_CACHE, true); } private Configuration getRevisionCacheConfig(long maxEntries) { @@ -270,4 +274,18 @@ public class DefaultInfinispanConnectionProviderFactory implements InfinispanCon return cb.build(); } + private Configuration getActionTokenCacheConfig() { + ConfigurationBuilder cb = new ConfigurationBuilder(); + + cb.eviction() + .strategy(EvictionStrategy.NONE) + .type(EvictionType.COUNT) + .size(InfinispanConnectionProvider.ACTION_TOKEN_CACHE_DEFAULT_MAX); + cb.expiration() + .maxIdle(InfinispanConnectionProvider.ACTION_TOKEN_MAX_IDLE_SECONDS, TimeUnit.SECONDS) + .wakeUpInterval(InfinispanConnectionProvider.ACTION_TOKEN_WAKE_UP_INTERVAL_SECONDS, TimeUnit.SECONDS); + + return cb.build(); + } + } diff --git a/model/infinispan/src/main/java/org/keycloak/connections/infinispan/InfinispanConnectionProvider.java b/model/infinispan/src/main/java/org/keycloak/connections/infinispan/InfinispanConnectionProvider.java index 6d8b7f4be9..ba9a31bd4d 100755 --- a/model/infinispan/src/main/java/org/keycloak/connections/infinispan/InfinispanConnectionProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/connections/infinispan/InfinispanConnectionProvider.java @@ -40,6 +40,11 @@ public interface InfinispanConnectionProvider extends Provider { String WORK_CACHE_NAME = "work"; String AUTHORIZATION_CACHE_NAME = "authorization"; + String ACTION_TOKEN_CACHE = "actionTokens"; + int ACTION_TOKEN_CACHE_DEFAULT_MAX = -1; + int ACTION_TOKEN_MAX_IDLE_SECONDS = -1; + long ACTION_TOKEN_WAKE_UP_INTERVAL_SECONDS = 5 * 60 * 1000l; + String KEYS_CACHE_NAME = "keys"; int KEYS_CACHE_DEFAULT_MAX = 1000; int KEYS_CACHE_MAX_IDLE_SECONDS = 3600; diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/AddInvalidatedActionTokenEvent.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/AddInvalidatedActionTokenEvent.java new file mode 100644 index 0000000000..37a1a218d5 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/AddInvalidatedActionTokenEvent.java @@ -0,0 +1,50 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.cache.infinispan; + +import org.keycloak.cluster.ClusterEvent; +import org.keycloak.models.sessions.infinispan.entities.ActionTokenReducedKey; +import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity; + +/** + * Event requesting adding of an invalidated action token. + */ +public class AddInvalidatedActionTokenEvent implements ClusterEvent { + + private final ActionTokenReducedKey key; + private final int expirationInSecs; + private final ActionTokenValueEntity tokenValue; + + public AddInvalidatedActionTokenEvent(ActionTokenReducedKey key, int expirationInSecs, ActionTokenValueEntity tokenValue) { + this.key = key; + this.expirationInSecs = expirationInSecs; + this.tokenValue = tokenValue; + } + + public ActionTokenReducedKey getKey() { + return key; + } + + public int getExpirationInSecs() { + return expirationInSecs; + } + + public ActionTokenValueEntity getTokenValue() { + return tokenValue; + } + +} 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 8350f0dc30..0bed8262a1 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 @@ -474,6 +474,30 @@ public class RealmAdapter implements CachedRealmModel { updated.setAccessCodeLifespanLogin(seconds); } + @Override + public int getActionTokenGeneratedByAdminLifespan() { + if (isUpdated()) return updated.getActionTokenGeneratedByAdminLifespan(); + return cached.getActionTokenGeneratedByAdminLifespan(); + } + + @Override + public void setActionTokenGeneratedByAdminLifespan(int seconds) { + getDelegateForUpdate(); + updated.setActionTokenGeneratedByAdminLifespan(seconds); + } + + @Override + public int getActionTokenGeneratedByUserLifespan() { + if (isUpdated()) return updated.getActionTokenGeneratedByUserLifespan(); + return cached.getActionTokenGeneratedByUserLifespan(); + } + + @Override + public void setActionTokenGeneratedByUserLifespan(int seconds) { + getDelegateForUpdate(); + updated.setActionTokenGeneratedByUserLifespan(seconds); + } + @Override public List getRequiredCredentials() { if (isUpdated()) return updated.getRequiredCredentials(); diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RemoveActionTokensSpecificEvent.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RemoveActionTokensSpecificEvent.java new file mode 100644 index 0000000000..0a4d858b52 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RemoveActionTokensSpecificEvent.java @@ -0,0 +1,42 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.cache.infinispan; + +import org.keycloak.cluster.ClusterEvent; + +/** + * Event requesting removal of the action tokens with the given user and action regardless of nonce. + */ +public class RemoveActionTokensSpecificEvent implements ClusterEvent { + + private final String userId; + private final String actionId; + + public RemoveActionTokensSpecificEvent(String userId, String actionId) { + this.userId = userId; + this.actionId = actionId; + } + + public String getUserId() { + return userId; + } + + public String getActionId() { + return actionId; + } + +} 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 fef0486af1..3668d9740e 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 @@ -83,6 +83,8 @@ public class CachedRealm extends AbstractExtendableRevisioned { protected int accessCodeLifespan; protected int accessCodeLifespanUserAction; protected int accessCodeLifespanLogin; + protected int actionTokenGeneratedByAdminLifespan; + protected int actionTokenGeneratedByUserLifespan; protected int notBefore; protected PasswordPolicy passwordPolicy; protected OTPPolicy otpPolicy; @@ -175,6 +177,8 @@ public class CachedRealm extends AbstractExtendableRevisioned { accessCodeLifespan = model.getAccessCodeLifespan(); accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction(); accessCodeLifespanLogin = model.getAccessCodeLifespanLogin(); + actionTokenGeneratedByAdminLifespan = model.getActionTokenGeneratedByAdminLifespan(); + actionTokenGeneratedByUserLifespan = model.getActionTokenGeneratedByUserLifespan(); notBefore = model.getNotBefore(); passwordPolicy = model.getPasswordPolicy(); otpPolicy = model.getOTPPolicy(); @@ -399,6 +403,14 @@ public class CachedRealm extends AbstractExtendableRevisioned { return accessCodeLifespanLogin; } + public int getActionTokenGeneratedByAdminLifespan() { + return actionTokenGeneratedByAdminLifespan; + } + + public int getActionTokenGeneratedByUserLifespan() { + return actionTokenGeneratedByUserLifespan; + } + public List getRequiredCredentials() { return requiredCredentials; } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanActionTokenStoreProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanActionTokenStoreProvider.java new file mode 100644 index 0000000000..127879a4d1 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanActionTokenStoreProvider.java @@ -0,0 +1,98 @@ +/* + * Copyright 2017 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.sessions.infinispan; + +import org.keycloak.cluster.ClusterProvider; +import org.keycloak.models.*; + +import org.keycloak.models.cache.infinispan.AddInvalidatedActionTokenEvent; +import org.keycloak.models.cache.infinispan.RemoveActionTokensSpecificEvent; +import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity; +import org.keycloak.models.sessions.infinispan.entities.ActionTokenReducedKey; +import java.util.*; +import org.infinispan.Cache; + +/** + * + * @author hmlnarik + */ +public class InfinispanActionTokenStoreProvider implements ActionTokenStoreProvider { + + private final Cache actionKeyCache; + private final InfinispanKeycloakTransaction tx; + private final KeycloakSession session; + + public InfinispanActionTokenStoreProvider(KeycloakSession session, Cache actionKeyCache) { + this.session = session; + this.actionKeyCache = actionKeyCache; + this.tx = new InfinispanKeycloakTransaction(); + + session.getTransactionManager().enlistAfterCompletion(tx); + } + + @Override + public void close() { + } + + @Override + public void put(ActionTokenKeyModel key, Map notes) { + if (key == null || key.getUserId() == null || key.getActionId() == null) { + return; + } + + ActionTokenReducedKey tokenKey = new ActionTokenReducedKey(key.getUserId(), key.getActionId(), key.getActionVerificationNonce()); + ActionTokenValueEntity tokenValue = new ActionTokenValueEntity(notes); + + ClusterProvider cluster = session.getProvider(ClusterProvider.class); + this.tx.notify(cluster, InfinispanActionTokenStoreProviderFactory.ACTION_TOKEN_EVENTS, new AddInvalidatedActionTokenEvent(tokenKey, key.getExpiration(), tokenValue), false); + } + + @Override + public ActionTokenValueModel get(ActionTokenKeyModel actionTokenKey) { + if (actionTokenKey == null || actionTokenKey.getUserId() == null || actionTokenKey.getActionId() == null) { + return null; + } + + ActionTokenReducedKey key = new ActionTokenReducedKey(actionTokenKey.getUserId(), actionTokenKey.getActionId(), actionTokenKey.getActionVerificationNonce()); + return this.actionKeyCache.getAdvancedCache().get(key); + } + + @Override + public ActionTokenValueModel remove(ActionTokenKeyModel actionTokenKey) { + if (actionTokenKey == null || actionTokenKey.getUserId() == null || actionTokenKey.getActionId() == null) { + return null; + } + + ActionTokenReducedKey key = new ActionTokenReducedKey(actionTokenKey.getUserId(), actionTokenKey.getActionId(), actionTokenKey.getActionVerificationNonce()); + ActionTokenValueEntity value = this.actionKeyCache.get(key); + + if (value != null) { + this.tx.remove(actionKeyCache, key); + } + + return value; + } + + public void removeAll(String userId, String actionId) { + if (userId == null || actionId == null) { + return; + } + + ClusterProvider cluster = session.getProvider(ClusterProvider.class); + this.tx.notify(cluster, InfinispanActionTokenStoreProviderFactory.ACTION_TOKEN_EVENTS, new RemoveActionTokensSpecificEvent(userId, actionId), false); + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanActionTokenStoreProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanActionTokenStoreProviderFactory.java new file mode 100644 index 0000000000..a8c5e3899e --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanActionTokenStoreProviderFactory.java @@ -0,0 +1,100 @@ +/* + * Copyright 2017 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.sessions.infinispan; + +import org.keycloak.Config; +import org.keycloak.Config.Scope; +import org.keycloak.cluster.ClusterProvider; +import org.keycloak.common.util.Time; +import org.keycloak.connections.infinispan.InfinispanConnectionProvider; +import org.keycloak.models.*; + +import org.keycloak.models.cache.infinispan.AddInvalidatedActionTokenEvent; +import org.keycloak.models.cache.infinispan.RemoveActionTokensSpecificEvent; +import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity; +import org.keycloak.models.sessions.infinispan.entities.ActionTokenReducedKey; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import org.infinispan.Cache; +import org.infinispan.context.Flag; + +/** + * + * @author hmlnarik + */ +public class InfinispanActionTokenStoreProviderFactory implements ActionTokenStoreProviderFactory { + + public static final String ACTION_TOKEN_EVENTS = "ACTION_TOKEN_EVENTS"; + + /** + * If expiration is set to this value, no expiration is set on the corresponding cache entry (hence cache default is honored) + */ + private static final int DEFAULT_CACHE_EXPIRATION = 0; + + private Config.Scope config; + + @Override + public ActionTokenStoreProvider create(KeycloakSession session) { + InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class); + Cache actionTokenCache = connections.getCache(InfinispanConnectionProvider.ACTION_TOKEN_CACHE); + + ClusterProvider cluster = session.getProvider(ClusterProvider.class); + + cluster.registerListener(ACTION_TOKEN_EVENTS, event -> { + if (event instanceof RemoveActionTokensSpecificEvent) { + RemoveActionTokensSpecificEvent e = (RemoveActionTokensSpecificEvent) event; + + actionTokenCache + .getAdvancedCache() + .withFlags(Flag.CACHE_MODE_LOCAL, Flag.SKIP_CACHE_LOAD) + .keySet() + .stream() + .filter(k -> Objects.equals(k.getUserId(), e.getUserId()) && Objects.equals(k.getActionId(), e.getActionId())) + .forEach(actionTokenCache::remove); + } else if (event instanceof AddInvalidatedActionTokenEvent) { + AddInvalidatedActionTokenEvent e = (AddInvalidatedActionTokenEvent) event; + + if (e.getExpirationInSecs() == DEFAULT_CACHE_EXPIRATION) { + actionTokenCache.put(e.getKey(), e.getTokenValue()); + } else { + actionTokenCache.put(e.getKey(), e.getTokenValue(), e.getExpirationInSecs() - Time.currentTime(), TimeUnit.SECONDS); + } + } + }); + + return new InfinispanActionTokenStoreProvider(session, actionTokenCache); + } + + @Override + public void init(Scope config) { + this.config = config; + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return "infinispan"; + } + +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProviderFactory.java index aa6ede3e9a..83e970ddaa 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanAuthenticationSessionProviderFactory.java @@ -42,6 +42,8 @@ public class InfinispanAuthenticationSessionProviderFactory implements Authentic private volatile Cache authSessionsCache; + public static final String AUTHENTICATION_SESSION_EVENTS = "AUTHENTICATION_SESSION_EVENTS"; + @Override public void init(Config.Scope config) { diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanKeycloakTransaction.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanKeycloakTransaction.java index 4ac40af7a9..5471184da9 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanKeycloakTransaction.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanKeycloakTransaction.java @@ -16,11 +16,14 @@ */ package org.keycloak.models.sessions.infinispan; +import org.keycloak.cluster.ClusterEvent; +import org.keycloak.cluster.ClusterProvider; import org.infinispan.context.Flag; import org.keycloak.models.KeycloakTransaction; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.concurrent.TimeUnit; import org.infinispan.Cache; import org.jboss.logging.Logger; @@ -32,12 +35,12 @@ public class InfinispanKeycloakTransaction implements KeycloakTransaction { private final static Logger log = Logger.getLogger(InfinispanKeycloakTransaction.class); public enum CacheOperation { - ADD, REMOVE, REPLACE, ADD_IF_ABSENT // ADD_IF_ABSENT throws an exception if there is existing value + ADD, ADD_WITH_LIFESPAN, REMOVE, REPLACE, ADD_IF_ABSENT // ADD_IF_ABSENT throws an exception if there is existing value } private boolean active; private boolean rollback; - private final Map tasks = new HashMap<>(); + private final Map tasks = new LinkedHashMap<>(); @Override public void begin() { @@ -80,7 +83,28 @@ public class InfinispanKeycloakTransaction implements KeycloakTransaction { if (tasks.containsKey(taskKey)) { throw new IllegalStateException("Can't add session: task in progress for session"); } else { - tasks.put(taskKey, new CacheTask<>(cache, CacheOperation.ADD, key, value)); + tasks.put(taskKey, new CacheTaskWithValue(value) { + @Override + public void execute() { + decorateCache(cache).put(key, value); + } + }); + } + } + + public void put(Cache cache, K key, V value, long lifespan, TimeUnit lifespanUnit) { + log.tracev("Adding cache operation: {0} on {1}", CacheOperation.ADD_WITH_LIFESPAN, key); + + Object taskKey = getTaskKey(cache, key); + if (tasks.containsKey(taskKey)) { + throw new IllegalStateException("Can't add session: task in progress for session"); + } else { + tasks.put(taskKey, new CacheTaskWithValue(value) { + @Override + public void execute() { + decorateCache(cache).put(key, value, lifespan, lifespanUnit); + } + }); } } @@ -91,7 +115,15 @@ public class InfinispanKeycloakTransaction implements KeycloakTransaction { if (tasks.containsKey(taskKey)) { throw new IllegalStateException("Can't add session: task in progress for session"); } else { - tasks.put(taskKey, new CacheTask<>(cache, CacheOperation.ADD_IF_ABSENT, key, value)); + tasks.put(taskKey, new CacheTaskWithValue(value) { + @Override + public void execute() { + V existing = cache.putIfAbsent(key, value); + if (existing != null) { + throw new IllegalStateException("There is already existing value in cache for key " + key); + } + } + }); } } @@ -101,40 +133,47 @@ public class InfinispanKeycloakTransaction implements KeycloakTransaction { Object taskKey = getTaskKey(cache, key); CacheTask current = tasks.get(taskKey); if (current != null) { - switch (current.operation) { - case ADD: - case ADD_IF_ABSENT: - case REPLACE: - current.value = value; - return; - case REMOVE: - return; + if (current instanceof CacheTaskWithValue) { + ((CacheTaskWithValue) current).setValue(value); } } else { - tasks.put(taskKey, new CacheTask<>(cache, CacheOperation.REPLACE, key, value)); + tasks.put(taskKey, new CacheTaskWithValue(value) { + @Override + public void execute() { + decorateCache(cache).replace(key, value); + } + }); } } + public void notify(ClusterProvider clusterProvider, String taskKey, ClusterEvent event, boolean ignoreSender) { + log.tracev("Adding cache operation SEND_EVENT: {0}", event); + + String theTaskKey = taskKey; + int i = 1; + while (tasks.containsKey(theTaskKey)) { + theTaskKey = taskKey + "-" + (i++); + } + + tasks.put(taskKey, () -> clusterProvider.notify(taskKey, event, ignoreSender)); + } + public void remove(Cache cache, K key) { log.tracev("Adding cache operation: {0} on {1}", CacheOperation.REMOVE, key); Object taskKey = getTaskKey(cache, key); - tasks.put(taskKey, new CacheTask<>(cache, CacheOperation.REMOVE, key, null)); + tasks.put(taskKey, () -> decorateCache(cache).remove(key)); } // This is for possibility to lookup for session by id, which was created in this transaction public V get(Cache cache, K key) { Object taskKey = getTaskKey(cache, key); - CacheTask current = tasks.get(taskKey); + CacheTask current = tasks.get(taskKey); if (current != null) { - switch (current.operation) { - case ADD: - case ADD_IF_ABSENT: - case REPLACE: - return current.value; - case REMOVE: - return null; + if (current instanceof CacheTaskWithValue) { + return ((CacheTaskWithValue) current).getValue(); } + return null; } // Should we have per-transaction cache for lookups? @@ -151,46 +190,29 @@ public class InfinispanKeycloakTransaction implements KeycloakTransaction { } } - public static class CacheTask { - private final Cache cache; - private final CacheOperation operation; - private final K key; - private V value; + public interface CacheTask { + void execute(); + } - public CacheTask(Cache cache, CacheOperation operation, K key, V value) { - this.cache = cache; - this.operation = operation; - this.key = key; + public abstract class CacheTaskWithValue implements CacheTask { + protected V value; + + public CacheTaskWithValue(V value) { this.value = value; } - public void execute() { - log.tracev("Executing cache operation: {0} on {1}", operation, key); - - switch (operation) { - case ADD: - decorateCache().put(key, value); - break; - case REMOVE: - decorateCache().remove(key); - break; - case REPLACE: - decorateCache().replace(key, value); - break; - case ADD_IF_ABSENT: - V existing = cache.putIfAbsent(key, value); - if (existing != null) { - throw new IllegalStateException("IllegalState. There is already existing value in cache for key " + key); - } - break; - } + public V getValue() { + return value; } - - // Ignore return values. Should have better performance within cluster / cross-dc env - private Cache decorateCache() { - return cache.getAdvancedCache() - .withFlags(Flag.IGNORE_RETURN_VALUES, Flag.SKIP_REMOTE_LOOKUP); + public void setValue(V value) { + this.value = value; } } + + // Ignore return values. Should have better performance within cluster / cross-dc env + private static Cache decorateCache(Cache cache) { + return cache.getAdvancedCache() + .withFlags(Flag.IGNORE_RETURN_VALUES, Flag.SKIP_REMOTE_LOOKUP); + } } \ No newline at end of file diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ActionTokenReducedKey.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ActionTokenReducedKey.java new file mode 100644 index 0000000000..173c43497d --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ActionTokenReducedKey.java @@ -0,0 +1,104 @@ +/* + * Copyright 2017 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.sessions.infinispan.entities; + +import java.io.*; +import java.util.Objects; +import java.util.UUID; +import org.infinispan.commons.marshall.Externalizer; +import org.infinispan.commons.marshall.SerializeWith; + +/** + * + * @author hmlnarik + */ +@SerializeWith(value = ActionTokenReducedKey.ExternalizerImpl.class) +public class ActionTokenReducedKey implements Serializable { + + private final String userId; + private final String actionId; + + /** + * Nonce that must match. + */ + private final UUID actionVerificationNonce; + + public ActionTokenReducedKey(String userId, String actionId, UUID actionVerificationNonce) { + this.userId = userId; + this.actionId = actionId; + this.actionVerificationNonce = actionVerificationNonce; + } + + public String getUserId() { + return userId; + } + + public String getActionId() { + return actionId; + } + + public UUID getActionVerificationNonce() { + return actionVerificationNonce; + } + + @Override + public int hashCode() { + int hash = 7; + hash = 71 * hash + Objects.hashCode(this.userId); + hash = 71 * hash + Objects.hashCode(this.actionId); + hash = 71 * hash + Objects.hashCode(this.actionVerificationNonce); + return hash; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final ActionTokenReducedKey other = (ActionTokenReducedKey) obj; + return Objects.equals(this.userId, other.getUserId()) + && Objects.equals(this.actionId, other.getActionId()) + && Objects.equals(this.actionVerificationNonce, other.getActionVerificationNonce()); + } + + public static class ExternalizerImpl implements Externalizer { + + @Override + public void writeObject(ObjectOutput output, ActionTokenReducedKey t) throws IOException { + output.writeUTF(t.userId); + output.writeUTF(t.actionId); + output.writeLong(t.actionVerificationNonce.getMostSignificantBits()); + output.writeLong(t.actionVerificationNonce.getLeastSignificantBits()); + } + + @Override + public ActionTokenReducedKey readObject(ObjectInput input) throws IOException, ClassNotFoundException { + return new ActionTokenReducedKey( + input.readUTF(), + input.readUTF(), + new UUID(input.readLong(), input.readLong()) + ); + } + } + +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ActionTokenValueEntity.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ActionTokenValueEntity.java new file mode 100644 index 0000000000..7c0f663da6 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ActionTokenValueEntity.java @@ -0,0 +1,76 @@ +/* + * Copyright 2017 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.sessions.infinispan.entities; + +import org.keycloak.models.ActionTokenValueModel; + +import java.io.*; +import java.util.*; +import org.infinispan.commons.marshall.Externalizer; +import org.infinispan.commons.marshall.SerializeWith; + +/** + * @author hmlnarik + */ +@SerializeWith(ActionTokenValueEntity.ExternalizerImpl.class) +public class ActionTokenValueEntity implements ActionTokenValueModel { + + private final Map notes; + + public ActionTokenValueEntity(Map notes) { + this.notes = notes == null ? Collections.EMPTY_MAP : new HashMap<>(notes); + } + + @Override + public Map getNotes() { + return Collections.unmodifiableMap(notes); + } + + @Override + public String getNote(String name) { + return notes.get(name); + } + + public static class ExternalizerImpl implements Externalizer { + + private static final int VERSION_1 = 1; + + @Override + public void writeObject(ObjectOutput output, ActionTokenValueEntity t) throws IOException { + output.writeByte(VERSION_1); + + output.writeBoolean(! t.notes.isEmpty()); + if (! t.notes.isEmpty()) { + output.writeObject(t.notes); + } + } + + @Override + public ActionTokenValueEntity readObject(ObjectInput input) throws IOException, ClassNotFoundException { + byte version = input.readByte(); + + if (version != VERSION_1) { + throw new IOException("Invalid version: " + version); + } + boolean notesEmpty = input.readBoolean(); + + Map notes = notesEmpty ? Collections.EMPTY_MAP : (Map) input.readObject(); + + return new ActionTokenValueEntity(notes); + } + } +} diff --git a/model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.ActionTokenStoreProviderFactory b/model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.ActionTokenStoreProviderFactory new file mode 100644 index 0000000000..4100eccf23 --- /dev/null +++ b/model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.ActionTokenStoreProviderFactory @@ -0,0 +1 @@ +org.keycloak.models.sessions.infinispan.InfinispanActionTokenStoreProviderFactory 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 58aa4246c6..b3b4db2a02 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 @@ -512,6 +512,26 @@ public class RealmAdapter implements RealmModel, JpaModel { em.flush(); } + @Override + public int getActionTokenGeneratedByAdminLifespan() { + return getAttribute(RealmAttributes.ACTION_TOKEN_GENERATED_BY_ADMIN_LIFESPAN, 12 * 60 * 60); + } + + @Override + public void setActionTokenGeneratedByAdminLifespan(int actionTokenGeneratedByAdminLifespan) { + setAttribute(RealmAttributes.ACTION_TOKEN_GENERATED_BY_ADMIN_LIFESPAN, actionTokenGeneratedByAdminLifespan); + } + + @Override + public int getActionTokenGeneratedByUserLifespan() { + return getAttribute(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN, getAccessCodeLifespanUserAction()); + } + + @Override + public void setActionTokenGeneratedByUserLifespan(int actionTokenGeneratedByUserLifespan) { + setAttribute(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN, actionTokenGeneratedByUserLifespan); + } + protected RequiredCredentialModel initRequiredCredentialModel(String type) { RequiredCredentialModel model = RequiredCredentialModel.BUILT_IN.get(type); if (model == null) { diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmAttributes.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmAttributes.java index 499a008647..6ee1074c6c 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmAttributes.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmAttributes.java @@ -26,4 +26,8 @@ public interface RealmAttributes { String DISPLAY_NAME_HTML = "displayNameHtml"; + String ACTION_TOKEN_GENERATED_BY_ADMIN_LIFESPAN = "actionTokenGeneratedByAdminLifespan"; + + String ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN = "actionTokenGeneratedByUserLifespan"; + } diff --git a/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionFactory.java b/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionFactory.java index 76dc582e0e..517b5f4a4c 100755 --- a/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/authentication/RequiredActionFactory.java @@ -35,4 +35,13 @@ public interface RequiredActionFactory extends ProviderFactory notes); + + /** + * Returns token corresponding to the given key from the internal action token store + * @param key key + * @return {@code null} if no token is found for given key and nonce, value otherwise + */ + ActionTokenValueModel get(ActionTokenKeyModel key); + + /** + * Removes token corresponding to the given key from the internal action token store, and returns the stored value + * @param key key + * @param nonce nonce that must match a given key + * @return {@code null} if no token is found for given key and nonce, value otherwise + */ + ActionTokenValueModel remove(ActionTokenKeyModel key); + + void removeAll(String userId, String actionId); + + +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/ActionTokenStoreProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/models/ActionTokenStoreProviderFactory.java new file mode 100644 index 0000000000..26d086d3a4 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/ActionTokenStoreProviderFactory.java @@ -0,0 +1,27 @@ +/* + * Copyright 2017 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models; + +import org.keycloak.provider.ProviderFactory; + +/** + * + * @author hmlnarik + */ +public interface ActionTokenStoreProviderFactory extends ProviderFactory { + +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/ActionTokenStoreSpi.java b/server-spi-private/src/main/java/org/keycloak/models/ActionTokenStoreSpi.java new file mode 100644 index 0000000000..66ee518006 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/ActionTokenStoreSpi.java @@ -0,0 +1,50 @@ +/* + * Copyright 2017 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models; + +import org.keycloak.provider.*; + +/** + * SPI for action tokens. + * + * @author hmlnarik + */ +public class ActionTokenStoreSpi implements Spi { + + public static final String NAME = "actionToken"; + + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public Class getProviderClass() { + return ActionTokenStoreProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return ActionTokenStoreProviderFactory.class; + } + +} 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 fb2c727433..43e8ef825d 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 @@ -303,6 +303,8 @@ public class ModelToRepresentation { rep.setAccessCodeLifespan(realm.getAccessCodeLifespan()); rep.setAccessCodeLifespanUserAction(realm.getAccessCodeLifespanUserAction()); rep.setAccessCodeLifespanLogin(realm.getAccessCodeLifespanLogin()); + rep.setActionTokenGeneratedByAdminLifespan(realm.getActionTokenGeneratedByAdminLifespan()); + rep.setActionTokenGeneratedByUserLifespan(realm.getActionTokenGeneratedByUserLifespan()); rep.setSmtpServer(new HashMap<>(realm.getSmtpConfig())); rep.setBrowserSecurityHeaders(realm.getBrowserSecurityHeaders()); rep.setAccountTheme(realm.getAccountTheme()); 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 b427477846..1c90b0f00c 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 @@ -189,6 +189,14 @@ public class RepresentationToModel { newRealm.setAccessCodeLifespanLogin(rep.getAccessCodeLifespanLogin()); else newRealm.setAccessCodeLifespanLogin(1800); + if (rep.getActionTokenGeneratedByAdminLifespan() != null) + newRealm.setActionTokenGeneratedByAdminLifespan(rep.getActionTokenGeneratedByAdminLifespan()); + else newRealm.setActionTokenGeneratedByAdminLifespan(12 * 60 * 60); + + if (rep.getActionTokenGeneratedByUserLifespan() != null) + newRealm.setActionTokenGeneratedByUserLifespan(rep.getActionTokenGeneratedByUserLifespan()); + else newRealm.setActionTokenGeneratedByUserLifespan(newRealm.getAccessCodeLifespanUserAction()); + if (rep.getSslRequired() != null) newRealm.setSslRequired(SslRequired.valueOf(rep.getSslRequired().toUpperCase())); if (rep.isRegistrationAllowed() != null) newRealm.setRegistrationAllowed(rep.isRegistrationAllowed()); @@ -812,6 +820,10 @@ public class RepresentationToModel { realm.setAccessCodeLifespanUserAction(rep.getAccessCodeLifespanUserAction()); if (rep.getAccessCodeLifespanLogin() != null) realm.setAccessCodeLifespanLogin(rep.getAccessCodeLifespanLogin()); + if (rep.getActionTokenGeneratedByAdminLifespan() != null) + realm.setActionTokenGeneratedByAdminLifespan(rep.getActionTokenGeneratedByAdminLifespan()); + if (rep.getActionTokenGeneratedByUserLifespan() != null) + realm.setActionTokenGeneratedByUserLifespan(rep.getActionTokenGeneratedByUserLifespan()); if (rep.getNotBefore() != null) realm.setNotBefore(rep.getNotBefore()); if (rep.getRevokeRefreshToken() != null) realm.setRevokeRefreshToken(rep.getRevokeRefreshToken()); if (rep.getAccessTokenLifespan() != null) realm.setAccessTokenLifespan(rep.getAccessTokenLifespan()); diff --git a/server-spi-private/src/main/java/org/keycloak/sessions/AuthenticationSessionProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/sessions/AuthenticationSessionProviderFactory.java index c8758cafdd..b182458b5e 100644 --- a/server-spi-private/src/main/java/org/keycloak/sessions/AuthenticationSessionProviderFactory.java +++ b/server-spi-private/src/main/java/org/keycloak/sessions/AuthenticationSessionProviderFactory.java @@ -23,6 +23,4 @@ import org.keycloak.provider.ProviderFactory; * @author Marek Posolda */ public interface AuthenticationSessionProviderFactory extends ProviderFactory { - // TODO:hmlnarik: move this constant out of an interface into a more appropriate class - public static final String AUTHENTICATION_SESSION_EVENTS = "AUTHENTICATION_SESSION_EVENTS"; } diff --git a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi index b046e7d5ff..c692d32649 100755 --- a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -19,6 +19,7 @@ org.keycloak.provider.ExceptionConverterSpi org.keycloak.storage.UserStorageProviderSpi org.keycloak.storage.federated.UserFederatedStorageProviderSpi org.keycloak.models.RealmSpi +org.keycloak.models.ActionTokenStoreSpi org.keycloak.models.UserSessionSpi org.keycloak.models.UserSpi org.keycloak.models.session.UserSessionPersisterSpi diff --git a/server-spi/src/main/java/org/keycloak/models/ActionTokenKeyModel.java b/server-spi/src/main/java/org/keycloak/models/ActionTokenKeyModel.java new file mode 100644 index 0000000000..cf9d7d02e1 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/ActionTokenKeyModel.java @@ -0,0 +1,46 @@ +/* + * Copyright 2017 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models; + +import java.util.UUID; + +/** + * + * @author hmlnarik + */ +public interface ActionTokenKeyModel { + + /** + * @return ID of user which this token is for. + */ + String getUserId(); + + /** + * @return Action identifier this token is for. + */ + String getActionId(); + + /** + * Returns absolute number of seconds since the epoch in UTC timezone when the token expires. + */ + int getExpiration(); + + /** + * @return Single-use random value used for verification whether the relevant action is allowed. + */ + UUID getActionVerificationNonce(); +} diff --git a/server-spi/src/main/java/org/keycloak/models/ActionTokenValueModel.java b/server-spi/src/main/java/org/keycloak/models/ActionTokenValueModel.java new file mode 100644 index 0000000000..ba01cb6cbb --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/ActionTokenValueModel.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models; + +import java.util.Map; +import java.util.UUID; + +/** + * This model represents contents of an action token shareable among Keycloak instances in the cluster. + * @author hmlnarik + */ +public interface ActionTokenValueModel { + + /** + * Returns unmodifiable map of all notes. + * @return see description. Returns empty map if no note is set, never returns {@code null}. + */ + Map getNotes(); + + /** + * Returns value of the given note (or {@code null} when no note of this name is present) + * @return see description + */ + String getNote(String name); +} 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 dc8bff5333..f6484d6f5b 100755 --- a/server-spi/src/main/java/org/keycloak/models/RealmModel.java +++ b/server-spi/src/main/java/org/keycloak/models/RealmModel.java @@ -191,6 +191,12 @@ public interface RealmModel extends RoleContainerModel { void setAccessCodeLifespanLogin(int seconds); + int getActionTokenGeneratedByAdminLifespan(); + void setActionTokenGeneratedByAdminLifespan(int seconds); + + int getActionTokenGeneratedByUserLifespan(); + void setActionTokenGeneratedByUserLifespan(int seconds); + List getRequiredCredentials(); void addRequiredCredential(String cred); diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/AbstractActionTokenHander.java b/services/src/main/java/org/keycloak/authentication/actiontoken/AbstractActionTokenHander.java index e8a9b0265e..52d94d95f7 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/AbstractActionTokenHander.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/AbstractActionTokenHander.java @@ -17,13 +17,11 @@ package org.keycloak.authentication.actiontoken; import org.keycloak.Config.Scope; -import org.keycloak.authentication.actiontoken.ActionTokenHandler; -import org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory; import org.keycloak.events.EventType; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; -import org.keycloak.representations.JsonWebToken; -import org.keycloak.services.messages.Messages; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.sessions.AuthenticationSessionModel; /** * @@ -92,4 +90,15 @@ public abstract class AbstractActionTokenHander im return token == null ? null : token.getAuthenticationSessionId(); } + @Override + public AuthenticationSessionModel startFreshAuthenticationSession(T token, ActionTokenContext tokenContext) { + AuthenticationSessionModel authSession = tokenContext.createAuthenticationSessionForClient(token.getIssuedFor()); + authSession.setAuthNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true"); + return authSession; + } + + @Override + public boolean canUseTokenRepeatedly(T token, ActionTokenContext tokenContext) { + return true; + } } diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/ActionTokenHandler.java b/services/src/main/java/org/keycloak/authentication/actiontoken/ActionTokenHandler.java index 4368a747ff..f8d02d3468 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/ActionTokenHandler.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/ActionTokenHandler.java @@ -17,20 +17,18 @@ package org.keycloak.authentication.actiontoken; import org.keycloak.TokenVerifier.Predicate; -import org.keycloak.authentication.AuthenticationProcessor; -import org.keycloak.common.VerificationException; import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; -import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.provider.Provider; import org.keycloak.representations.JsonWebToken; -import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.sessions.AuthenticationSessionModel; import javax.ws.rs.core.Response; /** * Handler of the action token. * + * @param Class implementing the action token + * * @author hmlnarik */ public interface ActionTokenHandler extends Provider { @@ -42,7 +40,6 @@ public interface ActionTokenHandler extends Provider { * @param token * @param tokenContext * @return - * @throws VerificationException */ Response handleToken(T token, ActionTokenContext tokenContext); @@ -96,10 +93,12 @@ public interface ActionTokenHandler extends Provider { * @param tokenContext * @return */ - default AuthenticationSessionModel startFreshAuthenticationSession(T token, ActionTokenContext tokenContext) { - AuthenticationSessionModel authSession = tokenContext.createAuthenticationSessionForClient(token.getIssuedFor()); - authSession.setAuthNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true"); - return authSession; - } + AuthenticationSessionModel startFreshAuthenticationSession(T token, ActionTokenContext tokenContext); + /** + * Returns {@code true} when the token can be used repeatedly to invoke the action, {@code false} when the token + * is intended to be for single use only. + * @return see above + */ + boolean canUseTokenRepeatedly(T token, ActionTokenContext tokenContext); } diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/DefaultActionToken.java b/services/src/main/java/org/keycloak/authentication/actiontoken/DefaultActionToken.java index 0f21a44df3..ba4488039a 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/DefaultActionToken.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/DefaultActionToken.java @@ -22,9 +22,7 @@ import org.keycloak.common.VerificationException; import org.keycloak.common.util.Time; import org.keycloak.jose.jws.JWSBuilder; -import org.keycloak.models.KeyManager; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; +import org.keycloak.models.*; import org.keycloak.services.Urls; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; @@ -37,12 +35,11 @@ import javax.ws.rs.core.UriInfo; * * @author hmlnarik */ -public class DefaultActionToken extends DefaultActionTokenKey { +public class DefaultActionToken extends DefaultActionTokenKey implements ActionTokenValueModel { - public static final String JSON_FIELD_ACTION_VERIFICATION_NONCE = "nonce"; public static final String JSON_FIELD_AUTHENTICATION_SESSION_ID = "asid"; - public static Predicate ACTION_TOKEN_BASIC_CHECKS = t -> { + public static final Predicate ACTION_TOKEN_BASIC_CHECKS = t -> { if (t.getActionVerificationNonce() == null) { throw new VerificationException("Nonce not present."); } @@ -53,15 +50,8 @@ public class DefaultActionToken extends DefaultActionTokenKey { /** * Single-use random value used for verification whether the relevant action is allowed. */ - @JsonProperty(value = JSON_FIELD_ACTION_VERIFICATION_NONCE, required = true) - private UUID actionVerificationNonce; - public DefaultActionToken() { - super(null, null); - } - - public DefaultActionToken(String userId, String actionId, int expirationInSecs) { - this(userId, actionId, expirationInSecs, UUID.randomUUID()); + super(null, null, 0, null); } /** @@ -72,11 +62,20 @@ public class DefaultActionToken extends DefaultActionTokenKey { * @param actionVerificationNonce */ protected DefaultActionToken(String userId, String actionId, int absoluteExpirationInSecs, UUID actionVerificationNonce) { - super(userId, actionId); - this.actionVerificationNonce = actionVerificationNonce == null ? UUID.randomUUID() : actionVerificationNonce; - expiration = absoluteExpirationInSecs; + super(userId, actionId, absoluteExpirationInSecs, actionVerificationNonce); } + /** + * + * @param userId User ID + * @param actionId Action ID + * @param absoluteExpirationInSecs Absolute expiration time in seconds in timezone of Keycloak. + * @param actionVerificationNonce + */ + protected DefaultActionToken(String userId, String actionId, int absoluteExpirationInSecs, UUID actionVerificationNonce, String authenticationSessionId) { + super(userId, actionId, absoluteExpirationInSecs, actionVerificationNonce); + setAuthenticationSessionId(authenticationSessionId); + } @JsonProperty(value = JSON_FIELD_AUTHENTICATION_SESSION_ID) public String getAuthenticationSessionId() { @@ -88,11 +87,8 @@ public class DefaultActionToken extends DefaultActionTokenKey { setOtherClaims(JSON_FIELD_AUTHENTICATION_SESSION_ID, authenticationSessionId); } - public UUID getActionVerificationNonce() { - return actionVerificationNonce; - } - @JsonIgnore + @Override public Map getNotes() { Map res = new HashMap<>(); if (getAuthenticationSessionId() != null) { @@ -101,6 +97,7 @@ public class DefaultActionToken extends DefaultActionTokenKey { return res; } + @Override public String getNote(String name) { Object res = getOtherClaims().get(name); return res instanceof String ? (String) res : null; diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/DefaultActionTokenKey.java b/services/src/main/java/org/keycloak/authentication/actiontoken/DefaultActionTokenKey.java index a5440a9b87..b41681f303 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/DefaultActionTokenKey.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/DefaultActionTokenKey.java @@ -16,31 +16,64 @@ */ package org.keycloak.authentication.actiontoken; +import org.keycloak.models.ActionTokenKeyModel; import org.keycloak.representations.JsonWebToken; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.UUID; /** * * @author hmlnarik */ -public class DefaultActionTokenKey extends JsonWebToken { +public class DefaultActionTokenKey extends JsonWebToken implements ActionTokenKeyModel { /** The authenticationSession note with ID of the user authenticated via the action token */ public static final String ACTION_TOKEN_USER_ID = "ACTION_TOKEN_USER"; - public DefaultActionTokenKey(String userId, String actionId) { - subject = userId; - type = actionId; + public static final String JSON_FIELD_ACTION_VERIFICATION_NONCE = "nonce"; + + @JsonProperty(value = JSON_FIELD_ACTION_VERIFICATION_NONCE, required = true) + private UUID actionVerificationNonce; + + public DefaultActionTokenKey(String userId, String actionId, int absoluteExpirationInSecs, UUID actionVerificationNonce) { + this.subject = userId; + this.type = actionId; + this.expiration = absoluteExpirationInSecs; + this.actionVerificationNonce = actionVerificationNonce == null ? UUID.randomUUID() : actionVerificationNonce; } @JsonIgnore + @Override public String getUserId() { return getSubject(); } @JsonIgnore + @Override public String getActionId() { return getType(); } + @Override + public UUID getActionVerificationNonce() { + return actionVerificationNonce; + } + + public String serializeKey() { + return String.format("%s.%d.%s.%s", getUserId(), getExpiration(), getActionVerificationNonce(), getActionId()); + } + + public static DefaultActionTokenKey from(String serializedKey) { + if (serializedKey == null) { + return null; + } + String[] parsed = serializedKey.split("\\.", 4); + if (parsed.length != 4) { + return null; + } + + return new DefaultActionTokenKey(parsed[0], parsed[3], Integer.parseInt(parsed[1]), UUID.fromString(parsed[2])); + } + } diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/execactions/ExecuteActionsActionToken.java b/services/src/main/java/org/keycloak/authentication/actiontoken/execactions/ExecuteActionsActionToken.java index 4be6f861a0..7c32e2dce5 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/execactions/ExecuteActionsActionToken.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/execactions/ExecuteActionsActionToken.java @@ -16,13 +16,10 @@ */ package org.keycloak.authentication.actiontoken.execactions; -import org.keycloak.TokenVerifier; import org.keycloak.authentication.actiontoken.DefaultActionToken; -import org.keycloak.common.VerificationException; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.LinkedList; import java.util.List; -import java.util.UUID; /** * @@ -34,15 +31,14 @@ public class ExecuteActionsActionToken extends DefaultActionToken { private static final String JSON_FIELD_REQUIRED_ACTIONS = "rqac"; private static final String JSON_FIELD_REDIRECT_URI = "reduri"; - public ExecuteActionsActionToken(String userId, int absoluteExpirationInSecs, UUID actionVerificationNonce, List requiredActions, String redirectUri, String clientId) { - super(userId, TOKEN_TYPE, absoluteExpirationInSecs, actionVerificationNonce); + public ExecuteActionsActionToken(String userId, int absoluteExpirationInSecs, List requiredActions, String redirectUri, String clientId) { + super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null); setRequiredActions(requiredActions == null ? new LinkedList<>() : new LinkedList<>(requiredActions)); setRedirectUri(redirectUri); this.issuedFor = clientId; } private ExecuteActionsActionToken() { - super(null, TOKEN_TYPE, -1, null); } @JsonProperty(value = JSON_FIELD_REQUIRED_ACTIONS) @@ -72,14 +68,4 @@ public class ExecuteActionsActionToken extends DefaultActionToken { setOtherClaims(JSON_FIELD_REDIRECT_URI, redirectUri); } } - - /** - * Returns a {@code ExecuteActionsActionToken} instance decoded from the given string. If decoding fails, returns {@code null} - * - * @param actionTokenString - * @return - */ - public static ExecuteActionsActionToken deserialize(String actionTokenString) throws VerificationException { - return TokenVerifier.create(actionTokenString, ExecuteActionsActionToken.class).getToken(); - } } diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/execactions/ExecuteActionsActionTokenHandler.java b/services/src/main/java/org/keycloak/authentication/actiontoken/execactions/ExecuteActionsActionTokenHandler.java index 010c5174a9..9993ab76e8 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/execactions/ExecuteActionsActionTokenHandler.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/execactions/ExecuteActionsActionTokenHandler.java @@ -17,15 +17,18 @@ package org.keycloak.authentication.actiontoken.execactions; import org.keycloak.TokenVerifier.Predicate; +import org.keycloak.authentication.RequiredActionFactory; +import org.keycloak.authentication.RequiredActionProvider; import org.keycloak.authentication.actiontoken.*; import org.keycloak.events.Errors; import org.keycloak.events.EventType; -import org.keycloak.models.UserModel; +import org.keycloak.models.*; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.messages.Messages; import org.keycloak.sessions.AuthenticationSessionModel; +import java.util.Objects; import javax.ws.rs.core.Response; /** @@ -48,7 +51,7 @@ public class ExecuteActionsActionTokenHandler extends AbstractActionTokenHander< public Predicate[] getVerifiers(ActionTokenContext tokenContext) { return TokenUtils.predicates( TokenUtils.checkThat( - // either redirect URI is not specified or must be valid for the cllient + // either redirect URI is not specified or must be valid for the client t -> t.getRedirectUri() == null || RedirectUtils.verifyRedirectUri(tokenContext.getUriInfo(), t.getRedirectUri(), tokenContext.getRealm(), tokenContext.getAuthenticationSession().getClient()) != null, @@ -81,4 +84,24 @@ public class ExecuteActionsActionTokenHandler extends AbstractActionTokenHander< String nextAction = AuthenticationManager.nextRequiredAction(tokenContext.getSession(), authSession, tokenContext.getClientConnection(), tokenContext.getRequest(), tokenContext.getUriInfo(), tokenContext.getEvent()); return AuthenticationManager.redirectToRequiredActions(tokenContext.getSession(), tokenContext.getRealm(), authSession, tokenContext.getUriInfo(), nextAction); } + + @Override + public boolean canUseTokenRepeatedly(ExecuteActionsActionToken token, ActionTokenContext tokenContext) { + RealmModel realm = tokenContext.getRealm(); + KeycloakSessionFactory sessionFactory = tokenContext.getSession().getKeycloakSessionFactory(); + + return token.getRequiredActions().stream() + .map(actionName -> realm.getRequiredActionProviderByAlias(actionName)) // get realm-specific model from action name and filter out irrelevant + .filter(Objects::nonNull) + .filter(RequiredActionProviderModel::isEnabled) + + .map(RequiredActionProviderModel::getProviderId) // get provider ID from model + + .map(providerId -> (RequiredActionFactory) sessionFactory.getProviderFactory(RequiredActionProvider.class, providerId)) + .filter(Objects::nonNull) + + .noneMatch(RequiredActionFactory::isOneTimeAction); + } + + } diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/idpverifyemail/IdpVerifyAccountLinkActionToken.java b/services/src/main/java/org/keycloak/authentication/actiontoken/idpverifyemail/IdpVerifyAccountLinkActionToken.java index ea705edd66..7776634193 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/idpverifyemail/IdpVerifyAccountLinkActionToken.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/idpverifyemail/IdpVerifyAccountLinkActionToken.java @@ -16,9 +16,7 @@ */ package org.keycloak.authentication.actiontoken.idpverifyemail; -import org.keycloak.authentication.actiontoken.verifyemail.*; import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.UUID; import org.keycloak.authentication.actiontoken.DefaultActionToken; /** @@ -39,16 +37,14 @@ public class IdpVerifyAccountLinkActionToken extends DefaultActionToken { @JsonProperty(value = JSON_FIELD_IDENTITY_PROVIDER_ALIAS) private String identityProviderAlias; - public IdpVerifyAccountLinkActionToken(String userId, int absoluteExpirationInSecs, UUID actionVerificationNonce, String authenticationSessionId, + public IdpVerifyAccountLinkActionToken(String userId, int absoluteExpirationInSecs, String authenticationSessionId, String identityProviderUsername, String identityProviderAlias) { - super(userId, TOKEN_TYPE, absoluteExpirationInSecs, actionVerificationNonce); - setAuthenticationSessionId(authenticationSessionId); + super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null, authenticationSessionId); this.identityProviderUsername = identityProviderUsername; this.identityProviderAlias = identityProviderAlias; } private IdpVerifyAccountLinkActionToken() { - super(null, TOKEN_TYPE, -1, null); } public String getIdentityProviderUsername() { diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/resetcred/ResetCredentialsActionToken.java b/services/src/main/java/org/keycloak/authentication/actiontoken/resetcred/ResetCredentialsActionToken.java index 67fb452658..6cd04589f2 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/resetcred/ResetCredentialsActionToken.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/resetcred/ResetCredentialsActionToken.java @@ -16,8 +16,6 @@ */ package org.keycloak.authentication.actiontoken.resetcred; -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.UUID; import org.keycloak.authentication.actiontoken.DefaultActionToken; /** @@ -28,26 +26,11 @@ import org.keycloak.authentication.actiontoken.DefaultActionToken; public class ResetCredentialsActionToken extends DefaultActionToken { public static final String TOKEN_TYPE = "reset-credentials"; - private static final String JSON_FIELD_LAST_CHANGE_PASSWORD_TIMESTAMP = "lcpt"; - @JsonProperty(value = JSON_FIELD_LAST_CHANGE_PASSWORD_TIMESTAMP) - private Long lastChangedPasswordTimestamp; - - public ResetCredentialsActionToken(String userId, int absoluteExpirationInSecs, UUID actionVerificationNonce, String authenticationSessionId, Long lastChangedPasswordTimestamp) { - super(userId, TOKEN_TYPE, absoluteExpirationInSecs, actionVerificationNonce); - setAuthenticationSessionId(authenticationSessionId); - this.lastChangedPasswordTimestamp = lastChangedPasswordTimestamp; + public ResetCredentialsActionToken(String userId, int absoluteExpirationInSecs, String authenticationSessionId) { + super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null, authenticationSessionId); } private ResetCredentialsActionToken() { - super(null, TOKEN_TYPE, -1, null); - } - - public Long getLastChangedPasswordTimestamp() { - return lastChangedPasswordTimestamp; - } - - public final void setLastChangedPasswordTimestamp(Long lastChangedPasswordTimestamp) { - this.lastChangedPasswordTimestamp = lastChangedPasswordTimestamp; } } diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/resetcred/ResetCredentialsActionTokenHandler.java b/services/src/main/java/org/keycloak/authentication/actiontoken/resetcred/ResetCredentialsActionTokenHandler.java index 34174311e7..0f08bd39a4 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/resetcred/ResetCredentialsActionTokenHandler.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/resetcred/ResetCredentialsActionTokenHandler.java @@ -25,7 +25,6 @@ import org.keycloak.events.Errors; import org.keycloak.events.EventType; import org.keycloak.models.UserModel; import org.keycloak.services.ErrorPage; -import org.keycloak.services.managers.AuthenticationSessionManager; import org.keycloak.services.messages.Messages; import org.keycloak.services.resources.LoginActionsService; import org.keycloak.services.resources.LoginActionsServiceChecks.IsActionRequired; @@ -55,9 +54,7 @@ public class ResetCredentialsActionTokenHandler extends AbstractActionTokenHande return new Predicate[] { TokenUtils.checkThat(tokenContext.getRealm()::isResetPasswordAllowed, Errors.NOT_ALLOWED, Messages.RESET_CREDENTIAL_NOT_ALLOWED), - new IsActionRequired(tokenContext, Action.AUTHENTICATE), - -// singleUseCheck, // TODO:hmlnarik - fix with single-use cache + new IsActionRequired(tokenContext, Action.AUTHENTICATE) }; } @@ -74,6 +71,11 @@ public class ResetCredentialsActionTokenHandler extends AbstractActionTokenHande ); } + @Override + public boolean canUseTokenRepeatedly(ResetCredentialsActionToken token, ActionTokenContext tokenContext) { + return false; + } + public static class ResetCredsAuthenticationProcessor extends AuthenticationProcessor { @Override diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionToken.java b/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionToken.java index 72e460c6b2..656c518718 100644 --- a/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionToken.java +++ b/services/src/main/java/org/keycloak/authentication/actiontoken/verifyemail/VerifyEmailActionToken.java @@ -17,7 +17,6 @@ package org.keycloak.authentication.actiontoken.verifyemail; import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.UUID; import org.keycloak.authentication.actiontoken.DefaultActionToken; /** @@ -34,15 +33,12 @@ public class VerifyEmailActionToken extends DefaultActionToken { @JsonProperty(value = JSON_FIELD_EMAIL) private String email; - public VerifyEmailActionToken(String userId, int absoluteExpirationInSecs, UUID actionVerificationNonce, String authenticationSessionId, - String email) { - super(userId, TOKEN_TYPE, absoluteExpirationInSecs, actionVerificationNonce); - setAuthenticationSessionId(authenticationSessionId); + public VerifyEmailActionToken(String userId, int absoluteExpirationInSecs, String authenticationSessionId, String email) { + super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null, authenticationSessionId); this.email = email; } private VerifyEmailActionToken() { - super(null, TOKEN_TYPE, -1, null); } public String getEmail() { diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java index d8b9b30689..11d9b913c4 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java @@ -108,7 +108,7 @@ public class IdpEmailVerificationAuthenticator extends AbstractIdpAuthenticator UriInfo uriInfo = session.getContext().getUri(); AuthenticationSessionModel authSession = context.getAuthenticationSession(); - int validityInSecs = realm.getAccessCodeLifespanUserAction(); + int validityInSecs = realm.getActionTokenGeneratedByAdminLifespan(); int absoluteExpirationInSecs = Time.currentTime() + validityInSecs; EventBuilder event = context.getEvent().clone().event(EventType.SEND_IDENTITY_PROVIDER_LINK) @@ -120,7 +120,7 @@ public class IdpEmailVerificationAuthenticator extends AbstractIdpAuthenticator .removeDetail(Details.AUTH_TYPE); IdpVerifyAccountLinkActionToken token = new IdpVerifyAccountLinkActionToken( - existingUser.getId(), absoluteExpirationInSecs, null, authSession.getId(), + existingUser.getId(), absoluteExpirationInSecs, authSession.getId(), brokerContext.getUsername(), brokerContext.getIdpConfig().getAlias() ); UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo)); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java index 4d9b42ecb3..4ac9bffdaa 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java @@ -85,15 +85,11 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory return; } - int validityInSecs = context.getRealm().getAccessCodeLifespanUserAction(); + int validityInSecs = context.getRealm().getActionTokenGeneratedByUserLifespan(); int absoluteExpirationInSecs = Time.currentTime() + validityInSecs; - KeycloakSession keycloakSession = context.getSession(); - Long lastCreatedPassword = getLastChangedTimestamp(keycloakSession, context.getRealm(), user); - // We send the secret in the email in a link as a query param. - ResetCredentialsActionToken token = new ResetCredentialsActionToken(user.getId(), absoluteExpirationInSecs, - null, authenticationSession.getId(), lastCreatedPassword); + ResetCredentialsActionToken token = new ResetCredentialsActionToken(user.getId(), absoluteExpirationInSecs, authenticationSession.getId()); String link = UriBuilder .fromUri(context.getActionTokenUrl(token.serialize(context.getSession(), context.getRealm(), context.getUriInfo()))) .build() @@ -101,6 +97,7 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs); try { context.getSession().getProvider(EmailTemplateProvider.class).setRealm(context.getRealm()).setUser(user).sendPasswordReset(link, expirationInMinutes); + event.clone().event(EventType.SEND_RESET_PASSWORD) .user(user) .detail(Details.USERNAME, username) diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java index 5a0e5bf1b5..6c7f7451af 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdatePassword.java @@ -157,4 +157,9 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac public String getId() { return UserModel.RequiredAction.UPDATE_PASSWORD.name(); } + + @Override + public boolean isOneTimeAction() { + return true; + } } diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java index 829c705f6d..de7a078ccd 100644 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/UpdateTotp.java @@ -118,4 +118,9 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory public String getId() { return UserModel.RequiredAction.CONFIGURE_TOTP.name(); } + + @Override + public boolean isOneTimeAction() { + return true; + } } diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java index ae569705d9..baa3c4e9b4 100755 --- a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java +++ b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java @@ -32,7 +32,6 @@ import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.models.*; -import org.keycloak.models.UserModel.RequiredAction; import org.keycloak.services.Urls; import org.keycloak.services.validation.Validation; @@ -132,20 +131,20 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor RealmModel realm = session.getContext().getRealm(); UriInfo uriInfo = session.getContext().getUri(); - int validityInSecs = realm.getAccessCodeLifespanUserAction(); + int validityInSecs = realm.getActionTokenGeneratedByUserLifespan(); int absoluteExpirationInSecs = Time.currentTime() + validityInSecs; -// ExecuteActionsActionToken token = new ExecuteActionsActionToken(user.getId(), absoluteExpirationInSecs, null, -// Collections.singletonList(UserModel.RequiredAction.VERIFY_EMAIL.name()), -// null, null); -// token.setAuthenticationSessionId(authenticationSession.getId()); - VerifyEmailActionToken token = new VerifyEmailActionToken(user.getId(), absoluteExpirationInSecs, null, authSession.getId(), user.getEmail()); + VerifyEmailActionToken token = new VerifyEmailActionToken(user.getId(), absoluteExpirationInSecs, authSession.getId(), user.getEmail()); UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo)); String link = builder.build(realm.getName()).toString(); long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs); try { - session.getProvider(EmailTemplateProvider.class).setRealm(realm).setUser(user).sendVerifyEmail(link, expirationInMinutes); + session + .getProvider(EmailTemplateProvider.class) + .setRealm(realm) + .setUser(user) + .sendVerifyEmail(link, expirationInMinutes); event.success(); } catch (EmailException e) { logger.error("Failed to send verification email", e); 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 6e7a91735f..7ac2deec5c 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -26,6 +26,7 @@ import org.keycloak.authentication.RequiredActionContext; import org.keycloak.authentication.RequiredActionContextResult; import org.keycloak.authentication.RequiredActionFactory; import org.keycloak.authentication.RequiredActionProvider; +import org.keycloak.authentication.actiontoken.DefaultActionTokenKey; import org.keycloak.broker.provider.IdentityProvider; import org.keycloak.common.ClientConnection; import org.keycloak.common.VerificationException; @@ -37,23 +38,10 @@ import org.keycloak.events.EventType; import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.jose.jws.AlgorithmType; import org.keycloak.jose.jws.JWSBuilder; -import org.keycloak.models.AuthenticatedClientSessionModel; -import org.keycloak.models.ClientModel; -import org.keycloak.models.ClientSessionModel; -import org.keycloak.models.ClientTemplateModel; -import org.keycloak.models.KeyManager; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.ProtocolMapperModel; -import org.keycloak.models.RealmModel; -import org.keycloak.models.RequiredActionProviderModel; -import org.keycloak.models.RoleModel; -import org.keycloak.models.UserConsentModel; -import org.keycloak.models.UserModel; -import org.keycloak.models.UserSessionModel; +import org.keycloak.models.*; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocol.Error; -import org.keycloak.protocol.RestartLoginCookie; import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.representations.AccessToken; import org.keycloak.services.ServicesLogger; @@ -77,11 +65,7 @@ import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriInfo; import java.net.URI; import java.security.PublicKey; -import java.util.Collection; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Set; +import java.util.*; /** * Stateless object that manages authentication @@ -92,6 +76,7 @@ import java.util.Set; public class AuthenticationManager { public static final String SET_REDIRECT_URI_AFTER_REQUIRED_ACTIONS= "SET_REDIRECT_URI_AFTER_REQUIRED_ACTIONS"; public static final String END_AFTER_REQUIRED_ACTIONS = "END_AFTER_REQUIRED_ACTIONS"; + public static final String INVALIDATE_ACTION_TOKEN = "INVALIDATE_ACTION_TOKEN"; // Last authenticated client in userSession. public static final String LAST_AUTHENTICATED_CLIENT = "LAST_AUTHENTICATED_CLIENT"; @@ -522,6 +507,16 @@ public class AuthenticationManager { public static Response finishedRequiredActions(KeycloakSession session, AuthenticationSessionModel authSession, UserSessionModel userSession, ClientConnection clientConnection, HttpRequest request, UriInfo uriInfo, EventBuilder event) { + String actionTokenKeyToInvalidate = authSession.getAuthNote(INVALIDATE_ACTION_TOKEN); + if (actionTokenKeyToInvalidate != null) { + ActionTokenKeyModel actionTokenKey = DefaultActionTokenKey.from(actionTokenKeyToInvalidate); + + if (actionTokenKey != null) { + ActionTokenStoreProvider actionTokenStore = session.getProvider(ActionTokenStoreProvider.class); + actionTokenStore.put(actionTokenKey, null); + } + } + if (authSession.getAuthNote(END_AFTER_REQUIRED_ACTIONS) != null) { LoginFormsProvider infoPage = session.getProvider(LoginFormsProvider.class) .setSuccess(Messages.ACCOUNT_UPDATED); diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java index 758b7a1c02..2a07253598 100755 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java @@ -504,8 +504,12 @@ public class LoginActionsService { authSession = tokenContext.getAuthenticationSession(); event = tokenContext.getEvent(); + event.event(handler.eventType()); - initLoginEvent(authSession); + if (! handler.canUseTokenRepeatedly(token, tokenContext)) { + LoginActionsServiceChecks.checkTokenWasNotUsedYet(token, tokenContext); + authSession.setAuthNote(AuthenticationManager.INVALIDATE_ACTION_TOKEN, token.serializeKey()); + } authSession.setAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID, token.getUserId()); diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsServiceChecks.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsServiceChecks.java index 6d42d255ff..87eaf20505 100644 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsServiceChecks.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsServiceChecks.java @@ -304,4 +304,12 @@ public class LoginActionsServiceChecks { return true; } + + public static void checkTokenWasNotUsedYet(T token, ActionTokenContext context) throws VerificationException { + ActionTokenStoreProvider actionTokenStore = context.getSession().getProvider(ActionTokenStoreProvider.class); + if (actionTokenStore.get(token) != null) { + throw new ExplainedTokenVerificationException(token, Errors.EXPIRED_CODE, Messages.EXPIRED_ACTION); + } + } + } 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 6f9c2c088a..815ee8981b 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 @@ -816,7 +816,7 @@ public class UsersResource { @QueryParam(OIDCLoginProtocol.CLIENT_ID_PARAM) String clientId) { List actions = new LinkedList<>(); actions.add(UserModel.RequiredAction.UPDATE_PASSWORD.name()); - return executeActionsEmail(id, redirectUri, clientId, actions); + return executeActionsEmail(id, redirectUri, clientId, null, actions); } @@ -831,6 +831,7 @@ public class UsersResource { * @param id User is * @param redirectUri Redirect uri * @param clientId Client id + * @param lifespan Number of seconds after which the generated token expires * @param actions required actions the user needs to complete * @return */ @@ -840,6 +841,7 @@ public class UsersResource { public Response executeActionsEmail(@PathParam("id") String id, @QueryParam(OIDCLoginProtocol.REDIRECT_URI_PARAM) String redirectUri, @QueryParam(OIDCLoginProtocol.CLIENT_ID_PARAM) String clientId, + @QueryParam("lifespan") Integer lifespan, List actions) { auth.requireManage(); @@ -881,9 +883,11 @@ public class UsersResource { } } - long relativeExpiration = realm.getAccessCodeLifespanUserAction(); - int expiration = Time.currentTime() + realm.getAccessCodeLifespanUserAction(); - ExecuteActionsActionToken token = new ExecuteActionsActionToken(id, expiration, UUID.randomUUID(), actions, redirectUri, clientId); + if (lifespan == null) { + lifespan = realm.getActionTokenGeneratedByAdminLifespan(); + } + int expiration = Time.currentTime() + lifespan; + ExecuteActionsActionToken token = new ExecuteActionsActionToken(id, expiration, actions, redirectUri, clientId); try { UriBuilder builder = LoginActionsService.actionTokenProcessor(uriInfo); @@ -894,7 +898,7 @@ public class UsersResource { this.session.getProvider(EmailTemplateProvider.class) .setRealm(realm) .setUser(user) - .sendExecuteActions(link, TimeUnit.SECONDS.toMinutes(relativeExpiration)); + .sendExecuteActions(link, TimeUnit.SECONDS.toMinutes(lifespan)); //audit.user(user).detail(Details.EMAIL, user.getEmail()).detail(Details.CODE_ID, accessCode.getCodeId()).success(); @@ -925,7 +929,7 @@ public class UsersResource { public Response sendVerifyEmail(@PathParam("id") String id, @QueryParam(OIDCLoginProtocol.REDIRECT_URI_PARAM) String redirectUri, @QueryParam(OIDCLoginProtocol.CLIENT_ID_PARAM) String clientId) { List actions = new LinkedList<>(); actions.add(UserModel.RequiredAction.VERIFY_EMAIL.name()); - return executeActionsEmail(id, redirectUri, clientId, actions); + return executeActionsEmail(id, redirectUri, clientId, null, actions); } @GET diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionEmailVerificationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionEmailVerificationTest.java index b31cdfccb5..9fd5c7ac57 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionEmailVerificationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionEmailVerificationTest.java @@ -358,8 +358,6 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo events.expectRequiredAction(EventType.VERIFY_EMAIL) .user(testUserId) - .detail(Details.USERNAME, "test-user@localhost") - .detail(Details.EMAIL, "test-user@localhost") .detail(Details.CODE_ID, Matchers.not(Matchers.is(mailCodeId))) .client(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID) // as authentication sessions are browser-specific, // the client and redirect_uri is unrelated to diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java index f34c32017f..3d99124fa6 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java @@ -45,6 +45,7 @@ import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.services.resources.RealmsResource; import org.keycloak.testsuite.page.LoginPasswordUpdatePage; +import org.keycloak.testsuite.pages.ErrorPage; import org.keycloak.testsuite.pages.InfoPage; import org.keycloak.testsuite.pages.LoginPage; import org.keycloak.testsuite.util.AdminEventPaths; @@ -68,6 +69,7 @@ import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -95,6 +97,9 @@ public class UserTest extends AbstractAdminTest { @Page protected InfoPage infoPage; + @Page + protected ErrorPage errorPage; + @Page protected LoginPage loginPage; @@ -546,8 +551,49 @@ public class UserTest extends AbstractAdminTest { driver.navigate().to(link); -// TODO:hmlnarik - return back once single-use cache would be implemented -// assertEquals("We're sorry...", driver.getTitle()); + assertEquals("We're sorry...", driver.getTitle()); + } + + @Test + public void sendResetPasswordEmailSuccessTokenShortLifespan() throws IOException, MessagingException { + UserRepresentation userRep = new UserRepresentation(); + userRep.setEnabled(true); + userRep.setUsername("user1"); + userRep.setEmail("user1@test.com"); + + String id = createUser(userRep); + + final AtomicInteger originalValue = new AtomicInteger(); + + RealmRepresentation realmRep = realm.toRepresentation(); + originalValue.set(realmRep.getActionTokenGeneratedByAdminLifespan()); + realmRep.setActionTokenGeneratedByAdminLifespan(60); + realm.update(realmRep); + + try { + UserResource user = realm.users().get(id); + List actions = new LinkedList<>(); + actions.add(UserModel.RequiredAction.UPDATE_PASSWORD.name()); + user.executeActionsEmail(actions); + + Assert.assertEquals(1, greenMail.getReceivedMessages().length); + + MimeMessage message = greenMail.getReceivedMessages()[0]; + + String link = MailUtils.getPasswordResetEmailLink(message); + + setTimeOffset(70); + + driver.navigate().to(link); + + errorPage.assertCurrent(); + assertEquals("An error occurred, please login again through your application.", errorPage.getError()); + } finally { + setTimeOffset(0); + + realmRep.setActionTokenGeneratedByAdminLifespan(originalValue.get()); + realm.update(realmRep); + } } @Test @@ -608,8 +654,7 @@ public class UserTest extends AbstractAdminTest { driver.navigate().to(link); -// TODO:hmlnarik - return back once single-use cache would be implemented -// assertEquals("We're sorry...", driver.getTitle()); + assertEquals("We're sorry...", driver.getTitle()); } @Test @@ -674,8 +719,7 @@ public class UserTest extends AbstractAdminTest { driver.navigate().to(link); -// TODO:hmlnarik - return back once single-use cache would be implemented -// assertEquals("We're sorry...", driver.getTitle()); + assertEquals("We're sorry...", driver.getTitle()); } @@ -734,6 +778,11 @@ public class UserTest extends AbstractAdminTest { driver.navigate().to(link); Assert.assertEquals("Your account has been updated.", infoPage.getInfo()); + + driver.navigate().to("about:blank"); + + driver.navigate().to(link); // It should be possible to use the same action token multiple times + Assert.assertEquals("Your account has been updated.", infoPage.getInfo()); } @Test 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 e5c128745a..ba0b7c9759 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 @@ -255,6 +255,8 @@ public class RealmTest extends AbstractAdminTest { rep.setSsoSessionIdleTimeout(123); rep.setSsoSessionMaxLifespan(12); rep.setAccessCodeLifespanLogin(1234); + rep.setActionTokenGeneratedByAdminLifespan(2345); + rep.setActionTokenGeneratedByUserLifespan(3456); rep.setRegistrationAllowed(true); rep.setRegistrationEmailAsUsername(true); rep.setEditUsernameAllowed(true); @@ -267,6 +269,8 @@ public class RealmTest extends AbstractAdminTest { assertEquals(123, rep.getSsoSessionIdleTimeout().intValue()); assertEquals(12, rep.getSsoSessionMaxLifespan().intValue()); assertEquals(1234, rep.getAccessCodeLifespanLogin().intValue()); + assertEquals(2345, rep.getActionTokenGeneratedByAdminLifespan().intValue()); + assertEquals(3456, rep.getActionTokenGeneratedByUserLifespan().intValue()); assertEquals(Boolean.TRUE, rep.isRegistrationAllowed()); assertEquals(Boolean.TRUE, rep.isRegistrationEmailAsUsername()); assertEquals(Boolean.TRUE, rep.isEditUsernameAllowed()); @@ -443,6 +447,12 @@ public class RealmTest extends AbstractAdminTest { if (realm.getAccessCodeLifespan() != null) assertEquals(realm.getAccessCodeLifespan(), storedRealm.getAccessCodeLifespan()); if (realm.getAccessCodeLifespanUserAction() != null) assertEquals(realm.getAccessCodeLifespanUserAction(), storedRealm.getAccessCodeLifespanUserAction()); + if (realm.getActionTokenGeneratedByAdminLifespan() != null) + assertEquals(realm.getActionTokenGeneratedByAdminLifespan(), storedRealm.getActionTokenGeneratedByAdminLifespan()); + if (realm.getActionTokenGeneratedByUserLifespan() != null) + assertEquals(realm.getActionTokenGeneratedByUserLifespan(), storedRealm.getActionTokenGeneratedByUserLifespan()); + else + assertEquals(realm.getAccessCodeLifespanUserAction(), storedRealm.getActionTokenGeneratedByUserLifespan()); if (realm.getNotBefore() != null) assertEquals(realm.getNotBefore(), storedRealm.getNotBefore()); if (realm.getAccessTokenLifespan() != null) assertEquals(realm.getAccessTokenLifespan(), storedRealm.getAccessTokenLifespan()); if (realm.getAccessTokenLifespanForImplicitFlow() != null) assertEquals(realm.getAccessTokenLifespanForImplicitFlow(), storedRealm.getAccessTokenLifespanForImplicitFlow()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java index f322c529e9..04ee91117f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java @@ -189,19 +189,17 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { } public void assertSecondPasswordResetFails(String changePasswordUrl, String clientId) { - // TODO:hmlnarik uncomment when single-use cache is implemented -// driver.navigate().to(changePasswordUrl.trim()); -// -// errorPage.assertCurrent(); -// assertEquals("An error occurred, please login again through your application.", errorPage.getError()); -// -// events.expect(EventType.RESET_PASSWORD) -// .client((String) null) -// .session((String) null) -// .user(userId) -// .detail(Details.USERNAME, "login-test") -// .error(Errors.EXPIRED_CODE) -// .assertEvent(); + driver.navigate().to(changePasswordUrl.trim()); + + errorPage.assertCurrent(); + assertEquals("Action expired. Please continue with login now.", errorPage.getError()); + + events.expect(EventType.RESET_PASSWORD) + .client("account") + .session((String) null) + .user(userId) + .error(Errors.EXPIRED_CODE) + .assertEvent(); } @Test @@ -386,8 +384,8 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { final AtomicInteger originalValue = new AtomicInteger(); RealmRepresentation realmRep = testRealm().toRepresentation(); - originalValue.set(realmRep.getAccessCodeLifespan()); - realmRep.setAccessCodeLifespanUserAction(60); + originalValue.set(realmRep.getActionTokenGeneratedByUserLifespan()); + realmRep.setActionTokenGeneratedByUserLifespan(60); testRealm().update(realmRep); try { @@ -415,7 +413,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { } finally { setTimeOffset(0); - realmRep.setAccessCodeLifespanUserAction(originalValue.get()); + realmRep.setActionTokenGeneratedByUserLifespan(originalValue.get()); testRealm().update(realmRep); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/testrealm.json b/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/testrealm.json index b20eb5da1f..ef6f1053f7 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/testrealm.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/admin-test/testrealm.json @@ -9,6 +9,8 @@ "publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", "requiredCredentials": [ "password" ], "defaultRoles": [ "user" ], + "actionTokenGeneratedByAdminLifespan": "147", + "actionTokenGeneratedByUserLifespan": "258", "smtpServer": { "from": "auto@keycloak.org", "host": "localhost", 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 8b776c6c5f..f129e459e5 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 @@ -108,6 +108,10 @@ 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 access-token-lifespan-for-implicit-flow.tooltip=Max time before an access token issued during OpenID Connect Implicit Flow is expired. This value is recommended to be shorter than SSO timeout. There is no possibility to refresh token during implicit flow, that's why there is separate timeout different to 'Access Token Lifespan'. +action-token-generated-by-admin-lifespan=Default Admin Action Token Lifespan +action-token-generated-by-admin-lifespan.tooltip=Max time before an action token generated via admin interface is expired. This value is recommended to be long to allow admins send e-mails for users that are currently offline. The default timeout can be overridden right before issuing the token. +action-token-generated-by-user-lifespan=User Action Token Lifespan +action-token-generated-by-user-lifespan.tooltip=Max time before an action token generated via user action (e.g. e-mail verification) is expired. This value is recommended to be short because it is expected that the user would react to self-created action token quickly. client-login-timeout=Client login timeout client-login-timeout.tooltip=Max time an client has to finish the access token protocol. This should normally be 1 minute. login-timeout=Login timeout @@ -1292,6 +1296,8 @@ credential-types=Credential Types manage-user-password=Manage Password disable-credentials=Disable Credentials credential-reset-actions=Credential Reset +credential-reset-actions-timeout=Token validity +credential-reset-actions-timeout.tooltip=Max time before the action token allowing execution of given actions is expired. ldap-mappers=LDAP Mappers create-ldap-mapper=Create LDAP mapper 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 bcc655fbcc..c658bb548f 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 @@ -1044,6 +1044,8 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $scope.realm.accessCodeLifespan = TimeUnit2.asUnit(realm.accessCodeLifespan); $scope.realm.accessCodeLifespanLogin = TimeUnit2.asUnit(realm.accessCodeLifespanLogin); $scope.realm.accessCodeLifespanUserAction = TimeUnit2.asUnit(realm.accessCodeLifespanUserAction); + $scope.realm.actionTokenGeneratedByAdminLifespan = TimeUnit2.asUnit(realm.actionTokenGeneratedByAdminLifespan); + $scope.realm.actionTokenGeneratedByUserLifespan = TimeUnit2.asUnit(realm.actionTokenGeneratedByUserLifespan); var oldCopy = angular.copy($scope.realm); $scope.changed = false; @@ -1063,6 +1065,8 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $scope.realm.accessCodeLifespan = $scope.realm.accessCodeLifespan.toSeconds(); $scope.realm.accessCodeLifespanUserAction = $scope.realm.accessCodeLifespanUserAction.toSeconds(); $scope.realm.accessCodeLifespanLogin = $scope.realm.accessCodeLifespanLogin.toSeconds(); + $scope.realm.actionTokenGeneratedByAdminLifespan = $scope.realm.actionTokenGeneratedByAdminLifespan.toSeconds(); + $scope.realm.actionTokenGeneratedByUserLifespan = $scope.realm.actionTokenGeneratedByUserLifespan.toSeconds(); Realm.update($scope.realm, function () { $route.reload(); diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js index 13d83436b7..2c3c34f8af 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js @@ -482,7 +482,7 @@ module.controller('UserDetailCtrl', function($scope, realm, user, BruteForceUser } }); -module.controller('UserCredentialsCtrl', function($scope, realm, user, $route, RequiredActions, User, UserExecuteActionsEmail, UserCredentials, Notifications, Dialog) { +module.controller('UserCredentialsCtrl', function($scope, realm, user, $route, RequiredActions, User, UserExecuteActionsEmail, UserCredentials, Notifications, Dialog, TimeUnit2) { console.log('UserCredentialsCtrl'); $scope.realm = realm; @@ -548,6 +548,7 @@ module.controller('UserCredentialsCtrl', function($scope, realm, user, $route, R }; $scope.emailActions = []; + $scope.emailActionsTimeout = TimeUnit2.asUnit(realm.actionTokenGeneratedByAdminLifespan); $scope.disableableCredentialTypes = []; $scope.sendExecuteActionsEmail = function() { @@ -556,7 +557,7 @@ module.controller('UserCredentialsCtrl', function($scope, realm, user, $route, R return; } Dialog.confirm('Send Email', 'Are you sure you want to send email to user?', function() { - UserExecuteActionsEmail.update({ realm: realm.realm, userId: user.id }, $scope.emailActions, function() { + UserExecuteActionsEmail.update({ realm: realm.realm, userId: user.id, lifespan: $scope.emailActionsLifespan.toSeconds() }, $scope.emailActions, function() { Notifications.success("Email sent to user"); $scope.emailActions = []; }, function() { diff --git a/themes/src/main/resources/theme/base/admin/resources/js/services.js b/themes/src/main/resources/theme/base/admin/resources/js/services.js index fe09ebbe28..e850b3bc4b 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/services.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/services.js @@ -496,7 +496,8 @@ module.factory('UserCredentials', function($resource) { module.factory('UserExecuteActionsEmail', function($resource) { return $resource(authUrl + '/admin/realms/:realm/users/:userId/execute-actions-email', { realm : '@realm', - userId : '@userId' + userId : '@userId', + lifespan : '@lifespan', }, { update : { method : 'PUT' 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 f0f9e58fb4..f508716538 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 @@ -141,6 +141,40 @@ +
+ + +
+ + +
+ + {{:: 'action-token-generated-by-user-lifespan.tooltip' | translate}} + +
+ +
+ + +
+ + +
+ + {{:: 'action-token-generated-by-admin-lifespan.tooltip' | translate}} + +
+
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/user-credentials.html b/themes/src/main/resources/theme/base/admin/resources/partials/user-credentials.html index d213fdd442..9f3512ee0a 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/user-credentials.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/user-credentials.html @@ -75,6 +75,20 @@
{{:: 'credentials.reset-actions.tooltip' | translate}}
+
+ + +
+ + +
+ {{:: 'credential-reset-actions-timeout.tooltip' | translate}} +
diff --git a/wildfly/server-subsystem/src/main/java/org/keycloak/subsystem/server/extension/KeycloakServerDeploymentProcessor.java b/wildfly/server-subsystem/src/main/java/org/keycloak/subsystem/server/extension/KeycloakServerDeploymentProcessor.java index 8567376001..d83cd189ea 100755 --- a/wildfly/server-subsystem/src/main/java/org/keycloak/subsystem/server/extension/KeycloakServerDeploymentProcessor.java +++ b/wildfly/server-subsystem/src/main/java/org/keycloak/subsystem/server/extension/KeycloakServerDeploymentProcessor.java @@ -41,12 +41,12 @@ import java.util.List; public class KeycloakServerDeploymentProcessor implements DeploymentUnitProcessor { private static final String[] CACHES = new String[] { - "realms", "users","sessions","authenticationSessions","offlineSessions","loginFailures","work","authorization","keys" + "realms", "users","sessions","authenticationSessions","offlineSessions","loginFailures","work","authorization","keys","actionTokens" }; // This param name is defined again in Keycloak Services class // org.keycloak.services.resources.KeycloakApplication. We have this value in - // two places to avoid dependency between Keycloak Subsystem and Keyclaok Services module. + // two places to avoid dependency between Keycloak Subsystem and Keycloak Services module. public static final String KEYCLOAK_CONFIG_PARAM_NAME = "org.keycloak.server-subsystem.Config"; @Override diff --git a/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-infinispan.xml b/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-infinispan.xml index 83f76549e7..0d3b4aad30 100755 --- a/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-infinispan.xml +++ b/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-infinispan.xml @@ -43,6 +43,10 @@ + + + + @@ -109,6 +113,10 @@ + + + + diff --git a/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-infinispan2.xml b/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-infinispan2.xml index 5e706dca8b..a76162b83a 100755 --- a/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-infinispan2.xml +++ b/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-infinispan2.xml @@ -43,6 +43,10 @@ + + + + @@ -112,6 +116,10 @@ + + + +