KEYCLOAK-4628 Single-use cache + its functionality incorporated into reset password token. Utilize single-use cache for relevant actions in execute-actions token

This commit is contained in:
Hynek Mlnarik 2017-05-05 01:20:58 +02:00 committed by mposolda
parent db8b733610
commit b8262a9f02
63 changed files with 1243 additions and 222 deletions

View file

@ -46,6 +46,8 @@ public class RealmRepresentation {
protected Integer accessCodeLifespan; protected Integer accessCodeLifespan;
protected Integer accessCodeLifespanUserAction; protected Integer accessCodeLifespanUserAction;
protected Integer accessCodeLifespanLogin; protected Integer accessCodeLifespanLogin;
protected Integer actionTokenGeneratedByAdminLifespan;
protected Integer actionTokenGeneratedByUserLifespan;
protected Boolean enabled; protected Boolean enabled;
protected String sslRequired; protected String sslRequired;
@Deprecated @Deprecated
@ -338,6 +340,22 @@ public class RealmRepresentation {
this.accessCodeLifespanLogin = accessCodeLifespanLogin; 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<String> getDefaultRoles() { public List<String> getDefaultRoles() {
return defaultRoles; return defaultRoles;
} }

View file

@ -93,6 +93,7 @@
<local-cache name="offlineSessions"/> <local-cache name="offlineSessions"/>
<local-cache name="loginFailures"/> <local-cache name="loginFailures"/>
<local-cache name="authorization"/> <local-cache name="authorization"/>
<local-cache name="actionTokens"/>
<local-cache name="work"/> <local-cache name="work"/>
<local-cache name="keys"> <local-cache name="keys">
<eviction max-entries="1000" strategy="LRU"/> <eviction max-entries="1000" strategy="LRU"/>

View file

@ -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: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/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=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) /extension=org.keycloak.keycloak-server-subsystem/:add(module=org.keycloak.keycloak-server-subsystem)

View file

@ -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: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/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=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) /extension=org.keycloak.keycloak-server-subsystem/:add(module=org.keycloak.keycloak-server-subsystem)

View file

@ -120,6 +120,7 @@ public class DefaultInfinispanConnectionProviderFactory implements InfinispanCon
cacheManager.getCache(InfinispanConnectionProvider.AUTHORIZATION_CACHE_NAME, true); cacheManager.getCache(InfinispanConnectionProvider.AUTHORIZATION_CACHE_NAME, true);
cacheManager.getCache(InfinispanConnectionProvider.AUTHENTICATION_SESSIONS_CACHE_NAME, true); cacheManager.getCache(InfinispanConnectionProvider.AUTHENTICATION_SESSIONS_CACHE_NAME, true);
cacheManager.getCache(InfinispanConnectionProvider.KEYS_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); logger.debugv("Using container managed Infinispan cache container, lookup={1}", cacheContainerLookup);
} catch (Exception e) { } catch (Exception e) {
@ -220,6 +221,9 @@ public class DefaultInfinispanConnectionProviderFactory implements InfinispanCon
cacheManager.defineConfiguration(InfinispanConnectionProvider.KEYS_CACHE_NAME, getKeysCacheConfig()); cacheManager.defineConfiguration(InfinispanConnectionProvider.KEYS_CACHE_NAME, getKeysCacheConfig());
cacheManager.getCache(InfinispanConnectionProvider.KEYS_CACHE_NAME, true); 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) { private Configuration getRevisionCacheConfig(long maxEntries) {
@ -270,4 +274,18 @@ public class DefaultInfinispanConnectionProviderFactory implements InfinispanCon
return cb.build(); 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();
}
} }

View file

@ -40,6 +40,11 @@ public interface InfinispanConnectionProvider extends Provider {
String WORK_CACHE_NAME = "work"; String WORK_CACHE_NAME = "work";
String AUTHORIZATION_CACHE_NAME = "authorization"; 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"; String KEYS_CACHE_NAME = "keys";
int KEYS_CACHE_DEFAULT_MAX = 1000; int KEYS_CACHE_DEFAULT_MAX = 1000;
int KEYS_CACHE_MAX_IDLE_SECONDS = 3600; int KEYS_CACHE_MAX_IDLE_SECONDS = 3600;

View file

@ -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;
}
}

View file

@ -474,6 +474,30 @@ public class RealmAdapter implements CachedRealmModel {
updated.setAccessCodeLifespanLogin(seconds); 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 @Override
public List<RequiredCredentialModel> getRequiredCredentials() { public List<RequiredCredentialModel> getRequiredCredentials() {
if (isUpdated()) return updated.getRequiredCredentials(); if (isUpdated()) return updated.getRequiredCredentials();

View file

@ -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;
}
}

View file

@ -83,6 +83,8 @@ public class CachedRealm extends AbstractExtendableRevisioned {
protected int accessCodeLifespan; protected int accessCodeLifespan;
protected int accessCodeLifespanUserAction; protected int accessCodeLifespanUserAction;
protected int accessCodeLifespanLogin; protected int accessCodeLifespanLogin;
protected int actionTokenGeneratedByAdminLifespan;
protected int actionTokenGeneratedByUserLifespan;
protected int notBefore; protected int notBefore;
protected PasswordPolicy passwordPolicy; protected PasswordPolicy passwordPolicy;
protected OTPPolicy otpPolicy; protected OTPPolicy otpPolicy;
@ -175,6 +177,8 @@ public class CachedRealm extends AbstractExtendableRevisioned {
accessCodeLifespan = model.getAccessCodeLifespan(); accessCodeLifespan = model.getAccessCodeLifespan();
accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction(); accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction();
accessCodeLifespanLogin = model.getAccessCodeLifespanLogin(); accessCodeLifespanLogin = model.getAccessCodeLifespanLogin();
actionTokenGeneratedByAdminLifespan = model.getActionTokenGeneratedByAdminLifespan();
actionTokenGeneratedByUserLifespan = model.getActionTokenGeneratedByUserLifespan();
notBefore = model.getNotBefore(); notBefore = model.getNotBefore();
passwordPolicy = model.getPasswordPolicy(); passwordPolicy = model.getPasswordPolicy();
otpPolicy = model.getOTPPolicy(); otpPolicy = model.getOTPPolicy();
@ -399,6 +403,14 @@ public class CachedRealm extends AbstractExtendableRevisioned {
return accessCodeLifespanLogin; return accessCodeLifespanLogin;
} }
public int getActionTokenGeneratedByAdminLifespan() {
return actionTokenGeneratedByAdminLifespan;
}
public int getActionTokenGeneratedByUserLifespan() {
return actionTokenGeneratedByUserLifespan;
}
public List<RequiredCredentialModel> getRequiredCredentials() { public List<RequiredCredentialModel> getRequiredCredentials() {
return requiredCredentials; return requiredCredentials;
} }

View file

@ -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<ActionTokenReducedKey, ActionTokenValueEntity> actionKeyCache;
private final InfinispanKeycloakTransaction tx;
private final KeycloakSession session;
public InfinispanActionTokenStoreProvider(KeycloakSession session, Cache<ActionTokenReducedKey, ActionTokenValueEntity> 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<String, String> 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);
}
}

View file

@ -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<ActionTokenReducedKey, ActionTokenValueEntity> 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";
}
}

View file

@ -42,6 +42,8 @@ public class InfinispanAuthenticationSessionProviderFactory implements Authentic
private volatile Cache<String, AuthenticationSessionEntity> authSessionsCache; private volatile Cache<String, AuthenticationSessionEntity> authSessionsCache;
public static final String AUTHENTICATION_SESSION_EVENTS = "AUTHENTICATION_SESSION_EVENTS";
@Override @Override
public void init(Config.Scope config) { public void init(Config.Scope config) {

View file

@ -16,11 +16,14 @@
*/ */
package org.keycloak.models.sessions.infinispan; package org.keycloak.models.sessions.infinispan;
import org.keycloak.cluster.ClusterEvent;
import org.keycloak.cluster.ClusterProvider;
import org.infinispan.context.Flag; import org.infinispan.context.Flag;
import org.keycloak.models.KeycloakTransaction; import org.keycloak.models.KeycloakTransaction;
import java.util.HashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.infinispan.Cache; import org.infinispan.Cache;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
@ -32,12 +35,12 @@ public class InfinispanKeycloakTransaction implements KeycloakTransaction {
private final static Logger log = Logger.getLogger(InfinispanKeycloakTransaction.class); private final static Logger log = Logger.getLogger(InfinispanKeycloakTransaction.class);
public enum CacheOperation { 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 active;
private boolean rollback; private boolean rollback;
private final Map<Object, CacheTask> tasks = new HashMap<>(); private final Map<Object, CacheTask> tasks = new LinkedHashMap<>();
@Override @Override
public void begin() { public void begin() {
@ -80,7 +83,28 @@ public class InfinispanKeycloakTransaction implements KeycloakTransaction {
if (tasks.containsKey(taskKey)) { if (tasks.containsKey(taskKey)) {
throw new IllegalStateException("Can't add session: task in progress for session"); throw new IllegalStateException("Can't add session: task in progress for session");
} else { } else {
tasks.put(taskKey, new CacheTask<>(cache, CacheOperation.ADD, key, value)); tasks.put(taskKey, new CacheTaskWithValue<V>(value) {
@Override
public void execute() {
decorateCache(cache).put(key, value);
}
});
}
}
public <K, V> void put(Cache<K, V> 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<V>(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)) { if (tasks.containsKey(taskKey)) {
throw new IllegalStateException("Can't add session: task in progress for session"); throw new IllegalStateException("Can't add session: task in progress for session");
} else { } else {
tasks.put(taskKey, new CacheTask<>(cache, CacheOperation.ADD_IF_ABSENT, key, value)); tasks.put(taskKey, new CacheTaskWithValue<V>(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); Object taskKey = getTaskKey(cache, key);
CacheTask current = tasks.get(taskKey); CacheTask current = tasks.get(taskKey);
if (current != null) { if (current != null) {
switch (current.operation) { if (current instanceof CacheTaskWithValue) {
case ADD: ((CacheTaskWithValue<V>) current).setValue(value);
case ADD_IF_ABSENT:
case REPLACE:
current.value = value;
return;
case REMOVE:
return;
} }
} else { } else {
tasks.put(taskKey, new CacheTask<>(cache, CacheOperation.REPLACE, key, value)); tasks.put(taskKey, new CacheTaskWithValue<V>(value) {
@Override
public void execute() {
decorateCache(cache).replace(key, value);
}
});
} }
} }
public <K, V> 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 <K, V> void remove(Cache<K, V> cache, K key) { public <K, V> void remove(Cache<K, V> cache, K key) {
log.tracev("Adding cache operation: {0} on {1}", CacheOperation.REMOVE, key); log.tracev("Adding cache operation: {0} on {1}", CacheOperation.REMOVE, key);
Object taskKey = getTaskKey(cache, 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 // This is for possibility to lookup for session by id, which was created in this transaction
public <K, V> V get(Cache<K, V> cache, K key) { public <K, V> V get(Cache<K, V> cache, K key) {
Object taskKey = getTaskKey(cache, key); Object taskKey = getTaskKey(cache, key);
CacheTask<K, V> current = tasks.get(taskKey); CacheTask<V> current = tasks.get(taskKey);
if (current != null) { if (current != null) {
switch (current.operation) { if (current instanceof CacheTaskWithValue) {
case ADD: return ((CacheTaskWithValue<V>) current).getValue();
case ADD_IF_ABSENT:
case REPLACE:
return current.value;
case REMOVE:
return null;
} }
return null;
} }
// Should we have per-transaction cache for lookups? // Should we have per-transaction cache for lookups?
@ -151,46 +190,29 @@ public class InfinispanKeycloakTransaction implements KeycloakTransaction {
} }
} }
public static class CacheTask<K, V> { public interface CacheTask<V> {
private final Cache<K, V> cache; void execute();
private final CacheOperation operation; }
private final K key;
private V value;
public CacheTask(Cache<K, V> cache, CacheOperation operation, K key, V value) { public abstract class CacheTaskWithValue<V> implements CacheTask<V> {
this.cache = cache; protected V value;
this.operation = operation;
this.key = key; public CacheTaskWithValue(V value) {
this.value = value; this.value = value;
} }
public void execute() { public V getValue() {
log.tracev("Executing cache operation: {0} on {1}", operation, key); return value;
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 void setValue(V value) {
// Ignore return values. Should have better performance within cluster / cross-dc env this.value = value;
private Cache<K, V> decorateCache() {
return cache.getAdvancedCache()
.withFlags(Flag.IGNORE_RETURN_VALUES, Flag.SKIP_REMOTE_LOOKUP);
} }
} }
// Ignore return values. Should have better performance within cluster / cross-dc env
private static <K, V> Cache<K, V> decorateCache(Cache<K, V> cache) {
return cache.getAdvancedCache()
.withFlags(Flag.IGNORE_RETURN_VALUES, Flag.SKIP_REMOTE_LOOKUP);
}
} }

View file

@ -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<ActionTokenReducedKey> {
@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())
);
}
}
}

View file

@ -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<String, String> notes;
public ActionTokenValueEntity(Map<String, String> notes) {
this.notes = notes == null ? Collections.EMPTY_MAP : new HashMap<>(notes);
}
@Override
public Map<String, String> getNotes() {
return Collections.unmodifiableMap(notes);
}
@Override
public String getNote(String name) {
return notes.get(name);
}
public static class ExternalizerImpl implements Externalizer<ActionTokenValueEntity> {
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<String, String> notes = notesEmpty ? Collections.EMPTY_MAP : (Map<String, String>) input.readObject();
return new ActionTokenValueEntity(notes);
}
}
}

View file

@ -0,0 +1 @@
org.keycloak.models.sessions.infinispan.InfinispanActionTokenStoreProviderFactory

View file

@ -512,6 +512,26 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
em.flush(); 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) { protected RequiredCredentialModel initRequiredCredentialModel(String type) {
RequiredCredentialModel model = RequiredCredentialModel.BUILT_IN.get(type); RequiredCredentialModel model = RequiredCredentialModel.BUILT_IN.get(type);
if (model == null) { if (model == null) {

View file

@ -26,4 +26,8 @@ public interface RealmAttributes {
String DISPLAY_NAME_HTML = "displayNameHtml"; String DISPLAY_NAME_HTML = "displayNameHtml";
String ACTION_TOKEN_GENERATED_BY_ADMIN_LIFESPAN = "actionTokenGeneratedByAdminLifespan";
String ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN = "actionTokenGeneratedByUserLifespan";
} }

View file

@ -35,4 +35,13 @@ public interface RequiredActionFactory extends ProviderFactory<RequiredActionPro
* @return * @return
*/ */
String getDisplayText(); String getDisplayText();
/**
* Flag indicating whether the execution of the required action by the same circumstances
* (e.g. by one and the same action token) should only be permitted once.
* @return
*/
default boolean isOneTimeAction() {
return false;
}
} }

View file

@ -0,0 +1,54 @@
/*
* 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.Provider;
import java.util.Map;
/**
* Internal action token store provider.
* @author hmlnarik
*/
public interface ActionTokenStoreProvider extends Provider {
/**
* Adds a given token to token store.
* @param actionTokenKey key
* @param notes Optional notes to be stored with the token. Can be {@code null} in which case it is treated as an empty map.
*/
void put(ActionTokenKeyModel actionTokenKey, Map<String, String> 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);
}

View file

@ -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<ActionTokenStoreProvider> {
}

View file

@ -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<? extends Provider> getProviderClass() {
return ActionTokenStoreProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return ActionTokenStoreProviderFactory.class;
}
}

View file

@ -303,6 +303,8 @@ public class ModelToRepresentation {
rep.setAccessCodeLifespan(realm.getAccessCodeLifespan()); rep.setAccessCodeLifespan(realm.getAccessCodeLifespan());
rep.setAccessCodeLifespanUserAction(realm.getAccessCodeLifespanUserAction()); rep.setAccessCodeLifespanUserAction(realm.getAccessCodeLifespanUserAction());
rep.setAccessCodeLifespanLogin(realm.getAccessCodeLifespanLogin()); rep.setAccessCodeLifespanLogin(realm.getAccessCodeLifespanLogin());
rep.setActionTokenGeneratedByAdminLifespan(realm.getActionTokenGeneratedByAdminLifespan());
rep.setActionTokenGeneratedByUserLifespan(realm.getActionTokenGeneratedByUserLifespan());
rep.setSmtpServer(new HashMap<>(realm.getSmtpConfig())); rep.setSmtpServer(new HashMap<>(realm.getSmtpConfig()));
rep.setBrowserSecurityHeaders(realm.getBrowserSecurityHeaders()); rep.setBrowserSecurityHeaders(realm.getBrowserSecurityHeaders());
rep.setAccountTheme(realm.getAccountTheme()); rep.setAccountTheme(realm.getAccountTheme());

View file

@ -189,6 +189,14 @@ public class RepresentationToModel {
newRealm.setAccessCodeLifespanLogin(rep.getAccessCodeLifespanLogin()); newRealm.setAccessCodeLifespanLogin(rep.getAccessCodeLifespanLogin());
else newRealm.setAccessCodeLifespanLogin(1800); 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) if (rep.getSslRequired() != null)
newRealm.setSslRequired(SslRequired.valueOf(rep.getSslRequired().toUpperCase())); newRealm.setSslRequired(SslRequired.valueOf(rep.getSslRequired().toUpperCase()));
if (rep.isRegistrationAllowed() != null) newRealm.setRegistrationAllowed(rep.isRegistrationAllowed()); if (rep.isRegistrationAllowed() != null) newRealm.setRegistrationAllowed(rep.isRegistrationAllowed());
@ -812,6 +820,10 @@ public class RepresentationToModel {
realm.setAccessCodeLifespanUserAction(rep.getAccessCodeLifespanUserAction()); realm.setAccessCodeLifespanUserAction(rep.getAccessCodeLifespanUserAction());
if (rep.getAccessCodeLifespanLogin() != null) if (rep.getAccessCodeLifespanLogin() != null)
realm.setAccessCodeLifespanLogin(rep.getAccessCodeLifespanLogin()); 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.getNotBefore() != null) realm.setNotBefore(rep.getNotBefore());
if (rep.getRevokeRefreshToken() != null) realm.setRevokeRefreshToken(rep.getRevokeRefreshToken()); if (rep.getRevokeRefreshToken() != null) realm.setRevokeRefreshToken(rep.getRevokeRefreshToken());
if (rep.getAccessTokenLifespan() != null) realm.setAccessTokenLifespan(rep.getAccessTokenLifespan()); if (rep.getAccessTokenLifespan() != null) realm.setAccessTokenLifespan(rep.getAccessTokenLifespan());

View file

@ -23,6 +23,4 @@ import org.keycloak.provider.ProviderFactory;
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a> * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/ */
public interface AuthenticationSessionProviderFactory extends ProviderFactory<AuthenticationSessionProvider> { public interface AuthenticationSessionProviderFactory extends ProviderFactory<AuthenticationSessionProvider> {
// TODO:hmlnarik: move this constant out of an interface into a more appropriate class
public static final String AUTHENTICATION_SESSION_EVENTS = "AUTHENTICATION_SESSION_EVENTS";
} }

View file

@ -19,6 +19,7 @@ org.keycloak.provider.ExceptionConverterSpi
org.keycloak.storage.UserStorageProviderSpi org.keycloak.storage.UserStorageProviderSpi
org.keycloak.storage.federated.UserFederatedStorageProviderSpi org.keycloak.storage.federated.UserFederatedStorageProviderSpi
org.keycloak.models.RealmSpi org.keycloak.models.RealmSpi
org.keycloak.models.ActionTokenStoreSpi
org.keycloak.models.UserSessionSpi org.keycloak.models.UserSessionSpi
org.keycloak.models.UserSpi org.keycloak.models.UserSpi
org.keycloak.models.session.UserSessionPersisterSpi org.keycloak.models.session.UserSessionPersisterSpi

View file

@ -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();
}

View file

@ -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<String,String> 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);
}

View file

@ -191,6 +191,12 @@ public interface RealmModel extends RoleContainerModel {
void setAccessCodeLifespanLogin(int seconds); void setAccessCodeLifespanLogin(int seconds);
int getActionTokenGeneratedByAdminLifespan();
void setActionTokenGeneratedByAdminLifespan(int seconds);
int getActionTokenGeneratedByUserLifespan();
void setActionTokenGeneratedByUserLifespan(int seconds);
List<RequiredCredentialModel> getRequiredCredentials(); List<RequiredCredentialModel> getRequiredCredentials();
void addRequiredCredential(String cred); void addRequiredCredential(String cred);

View file

@ -17,13 +17,11 @@
package org.keycloak.authentication.actiontoken; package org.keycloak.authentication.actiontoken;
import org.keycloak.Config.Scope; 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.events.EventType;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.representations.JsonWebToken; import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages; import org.keycloak.sessions.AuthenticationSessionModel;
/** /**
* *
@ -92,4 +90,15 @@ public abstract class AbstractActionTokenHander<T extends DefaultActionToken> im
return token == null ? null : token.getAuthenticationSessionId(); return token == null ? null : token.getAuthenticationSessionId();
} }
@Override
public AuthenticationSessionModel startFreshAuthenticationSession(T token, ActionTokenContext<T> tokenContext) {
AuthenticationSessionModel authSession = tokenContext.createAuthenticationSessionForClient(token.getIssuedFor());
authSession.setAuthNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true");
return authSession;
}
@Override
public boolean canUseTokenRepeatedly(T token, ActionTokenContext<T> tokenContext) {
return true;
}
} }

View file

@ -17,20 +17,18 @@
package org.keycloak.authentication.actiontoken; package org.keycloak.authentication.actiontoken;
import org.keycloak.TokenVerifier.Predicate; import org.keycloak.TokenVerifier.Predicate;
import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.common.VerificationException;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.provider.Provider; import org.keycloak.provider.Provider;
import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
/** /**
* Handler of the action token. * Handler of the action token.
* *
* @param <T> Class implementing the action token
*
* @author hmlnarik * @author hmlnarik
*/ */
public interface ActionTokenHandler<T extends JsonWebToken> extends Provider { public interface ActionTokenHandler<T extends JsonWebToken> extends Provider {
@ -42,7 +40,6 @@ public interface ActionTokenHandler<T extends JsonWebToken> extends Provider {
* @param token * @param token
* @param tokenContext * @param tokenContext
* @return * @return
* @throws VerificationException
*/ */
Response handleToken(T token, ActionTokenContext<T> tokenContext); Response handleToken(T token, ActionTokenContext<T> tokenContext);
@ -96,10 +93,12 @@ public interface ActionTokenHandler<T extends JsonWebToken> extends Provider {
* @param tokenContext * @param tokenContext
* @return * @return
*/ */
default AuthenticationSessionModel startFreshAuthenticationSession(T token, ActionTokenContext<T> tokenContext) { AuthenticationSessionModel startFreshAuthenticationSession(T token, ActionTokenContext<T> tokenContext);
AuthenticationSessionModel authSession = tokenContext.createAuthenticationSessionForClient(token.getIssuedFor());
authSession.setAuthNote(AuthenticationManager.END_AFTER_REQUIRED_ACTIONS, "true");
return authSession;
}
/**
* 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<T> tokenContext);
} }

View file

@ -22,9 +22,7 @@ import org.keycloak.common.VerificationException;
import org.keycloak.common.util.Time; import org.keycloak.common.util.Time;
import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.models.KeyManager; import org.keycloak.models.*;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.services.Urls; import org.keycloak.services.Urls;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
@ -37,12 +35,11 @@ import javax.ws.rs.core.UriInfo;
* *
* @author hmlnarik * @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 final String JSON_FIELD_AUTHENTICATION_SESSION_ID = "asid";
public static Predicate<DefaultActionToken> ACTION_TOKEN_BASIC_CHECKS = t -> { public static final Predicate<DefaultActionToken> ACTION_TOKEN_BASIC_CHECKS = t -> {
if (t.getActionVerificationNonce() == null) { if (t.getActionVerificationNonce() == null) {
throw new VerificationException("Nonce not present."); 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. * 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() { public DefaultActionToken() {
super(null, null); super(null, null, 0, null);
}
public DefaultActionToken(String userId, String actionId, int expirationInSecs) {
this(userId, actionId, expirationInSecs, UUID.randomUUID());
} }
/** /**
@ -72,11 +62,20 @@ public class DefaultActionToken extends DefaultActionTokenKey {
* @param actionVerificationNonce * @param actionVerificationNonce
*/ */
protected DefaultActionToken(String userId, String actionId, int absoluteExpirationInSecs, UUID actionVerificationNonce) { protected DefaultActionToken(String userId, String actionId, int absoluteExpirationInSecs, UUID actionVerificationNonce) {
super(userId, actionId); super(userId, actionId, absoluteExpirationInSecs, actionVerificationNonce);
this.actionVerificationNonce = actionVerificationNonce == null ? UUID.randomUUID() : actionVerificationNonce;
expiration = absoluteExpirationInSecs;
} }
/**
*
* @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) @JsonProperty(value = JSON_FIELD_AUTHENTICATION_SESSION_ID)
public String getAuthenticationSessionId() { public String getAuthenticationSessionId() {
@ -88,11 +87,8 @@ public class DefaultActionToken extends DefaultActionTokenKey {
setOtherClaims(JSON_FIELD_AUTHENTICATION_SESSION_ID, authenticationSessionId); setOtherClaims(JSON_FIELD_AUTHENTICATION_SESSION_ID, authenticationSessionId);
} }
public UUID getActionVerificationNonce() {
return actionVerificationNonce;
}
@JsonIgnore @JsonIgnore
@Override
public Map<String, String> getNotes() { public Map<String, String> getNotes() {
Map<String, String> res = new HashMap<>(); Map<String, String> res = new HashMap<>();
if (getAuthenticationSessionId() != null) { if (getAuthenticationSessionId() != null) {
@ -101,6 +97,7 @@ public class DefaultActionToken extends DefaultActionTokenKey {
return res; return res;
} }
@Override
public String getNote(String name) { public String getNote(String name) {
Object res = getOtherClaims().get(name); Object res = getOtherClaims().get(name);
return res instanceof String ? (String) res : null; return res instanceof String ? (String) res : null;

View file

@ -16,31 +16,64 @@
*/ */
package org.keycloak.authentication.actiontoken; package org.keycloak.authentication.actiontoken;
import org.keycloak.models.ActionTokenKeyModel;
import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.JsonWebToken;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.UUID;
/** /**
* *
* @author hmlnarik * @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 */ /** 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 static final String ACTION_TOKEN_USER_ID = "ACTION_TOKEN_USER";
public DefaultActionTokenKey(String userId, String actionId) { public static final String JSON_FIELD_ACTION_VERIFICATION_NONCE = "nonce";
subject = userId;
type = actionId; @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 @JsonIgnore
@Override
public String getUserId() { public String getUserId() {
return getSubject(); return getSubject();
} }
@JsonIgnore @JsonIgnore
@Override
public String getActionId() { public String getActionId() {
return getType(); 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]));
}
} }

View file

@ -16,13 +16,10 @@
*/ */
package org.keycloak.authentication.actiontoken.execactions; package org.keycloak.authentication.actiontoken.execactions;
import org.keycloak.TokenVerifier;
import org.keycloak.authentication.actiontoken.DefaultActionToken; import org.keycloak.authentication.actiontoken.DefaultActionToken;
import org.keycloak.common.VerificationException;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; 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_REQUIRED_ACTIONS = "rqac";
private static final String JSON_FIELD_REDIRECT_URI = "reduri"; private static final String JSON_FIELD_REDIRECT_URI = "reduri";
public ExecuteActionsActionToken(String userId, int absoluteExpirationInSecs, UUID actionVerificationNonce, List<String> requiredActions, String redirectUri, String clientId) { public ExecuteActionsActionToken(String userId, int absoluteExpirationInSecs, List<String> requiredActions, String redirectUri, String clientId) {
super(userId, TOKEN_TYPE, absoluteExpirationInSecs, actionVerificationNonce); super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null);
setRequiredActions(requiredActions == null ? new LinkedList<>() : new LinkedList<>(requiredActions)); setRequiredActions(requiredActions == null ? new LinkedList<>() : new LinkedList<>(requiredActions));
setRedirectUri(redirectUri); setRedirectUri(redirectUri);
this.issuedFor = clientId; this.issuedFor = clientId;
} }
private ExecuteActionsActionToken() { private ExecuteActionsActionToken() {
super(null, TOKEN_TYPE, -1, null);
} }
@JsonProperty(value = JSON_FIELD_REQUIRED_ACTIONS) @JsonProperty(value = JSON_FIELD_REQUIRED_ACTIONS)
@ -72,14 +68,4 @@ public class ExecuteActionsActionToken extends DefaultActionToken {
setOtherClaims(JSON_FIELD_REDIRECT_URI, redirectUri); 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();
}
} }

View file

@ -17,15 +17,18 @@
package org.keycloak.authentication.actiontoken.execactions; package org.keycloak.authentication.actiontoken.execactions;
import org.keycloak.TokenVerifier.Predicate; import org.keycloak.TokenVerifier.Predicate;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.authentication.actiontoken.*; import org.keycloak.authentication.actiontoken.*;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.EventType; 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.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import java.util.Objects;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
/** /**
@ -48,7 +51,7 @@ public class ExecuteActionsActionTokenHandler extends AbstractActionTokenHander<
public Predicate<? super ExecuteActionsActionToken>[] getVerifiers(ActionTokenContext<ExecuteActionsActionToken> tokenContext) { public Predicate<? super ExecuteActionsActionToken>[] getVerifiers(ActionTokenContext<ExecuteActionsActionToken> tokenContext) {
return TokenUtils.predicates( return TokenUtils.predicates(
TokenUtils.checkThat( 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 t -> t.getRedirectUri() == null
|| RedirectUtils.verifyRedirectUri(tokenContext.getUriInfo(), t.getRedirectUri(), || RedirectUtils.verifyRedirectUri(tokenContext.getUriInfo(), t.getRedirectUri(),
tokenContext.getRealm(), tokenContext.getAuthenticationSession().getClient()) != null, 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()); 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); return AuthenticationManager.redirectToRequiredActions(tokenContext.getSession(), tokenContext.getRealm(), authSession, tokenContext.getUriInfo(), nextAction);
} }
@Override
public boolean canUseTokenRepeatedly(ExecuteActionsActionToken token, ActionTokenContext<ExecuteActionsActionToken> 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);
}
} }

View file

@ -16,9 +16,7 @@
*/ */
package org.keycloak.authentication.actiontoken.idpverifyemail; package org.keycloak.authentication.actiontoken.idpverifyemail;
import org.keycloak.authentication.actiontoken.verifyemail.*;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.UUID;
import org.keycloak.authentication.actiontoken.DefaultActionToken; import org.keycloak.authentication.actiontoken.DefaultActionToken;
/** /**
@ -39,16 +37,14 @@ public class IdpVerifyAccountLinkActionToken extends DefaultActionToken {
@JsonProperty(value = JSON_FIELD_IDENTITY_PROVIDER_ALIAS) @JsonProperty(value = JSON_FIELD_IDENTITY_PROVIDER_ALIAS)
private String identityProviderAlias; 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) { String identityProviderUsername, String identityProviderAlias) {
super(userId, TOKEN_TYPE, absoluteExpirationInSecs, actionVerificationNonce); super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null, authenticationSessionId);
setAuthenticationSessionId(authenticationSessionId);
this.identityProviderUsername = identityProviderUsername; this.identityProviderUsername = identityProviderUsername;
this.identityProviderAlias = identityProviderAlias; this.identityProviderAlias = identityProviderAlias;
} }
private IdpVerifyAccountLinkActionToken() { private IdpVerifyAccountLinkActionToken() {
super(null, TOKEN_TYPE, -1, null);
} }
public String getIdentityProviderUsername() { public String getIdentityProviderUsername() {

View file

@ -16,8 +16,6 @@
*/ */
package org.keycloak.authentication.actiontoken.resetcred; package org.keycloak.authentication.actiontoken.resetcred;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.UUID;
import org.keycloak.authentication.actiontoken.DefaultActionToken; import org.keycloak.authentication.actiontoken.DefaultActionToken;
/** /**
@ -28,26 +26,11 @@ import org.keycloak.authentication.actiontoken.DefaultActionToken;
public class ResetCredentialsActionToken extends DefaultActionToken { public class ResetCredentialsActionToken extends DefaultActionToken {
public static final String TOKEN_TYPE = "reset-credentials"; 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) public ResetCredentialsActionToken(String userId, int absoluteExpirationInSecs, String authenticationSessionId) {
private Long lastChangedPasswordTimestamp; super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null, authenticationSessionId);
public ResetCredentialsActionToken(String userId, int absoluteExpirationInSecs, UUID actionVerificationNonce, String authenticationSessionId, Long lastChangedPasswordTimestamp) {
super(userId, TOKEN_TYPE, absoluteExpirationInSecs, actionVerificationNonce);
setAuthenticationSessionId(authenticationSessionId);
this.lastChangedPasswordTimestamp = lastChangedPasswordTimestamp;
} }
private ResetCredentialsActionToken() { private ResetCredentialsActionToken() {
super(null, TOKEN_TYPE, -1, null);
}
public Long getLastChangedPasswordTimestamp() {
return lastChangedPasswordTimestamp;
}
public final void setLastChangedPasswordTimestamp(Long lastChangedPasswordTimestamp) {
this.lastChangedPasswordTimestamp = lastChangedPasswordTimestamp;
} }
} }

View file

@ -25,7 +25,6 @@ import org.keycloak.events.Errors;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.services.ErrorPage; import org.keycloak.services.ErrorPage;
import org.keycloak.services.managers.AuthenticationSessionManager;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.LoginActionsService; import org.keycloak.services.resources.LoginActionsService;
import org.keycloak.services.resources.LoginActionsServiceChecks.IsActionRequired; import org.keycloak.services.resources.LoginActionsServiceChecks.IsActionRequired;
@ -55,9 +54,7 @@ public class ResetCredentialsActionTokenHandler extends AbstractActionTokenHande
return new Predicate[] { return new Predicate[] {
TokenUtils.checkThat(tokenContext.getRealm()::isResetPasswordAllowed, Errors.NOT_ALLOWED, Messages.RESET_CREDENTIAL_NOT_ALLOWED), TokenUtils.checkThat(tokenContext.getRealm()::isResetPasswordAllowed, Errors.NOT_ALLOWED, Messages.RESET_CREDENTIAL_NOT_ALLOWED),
new IsActionRequired(tokenContext, Action.AUTHENTICATE), new IsActionRequired(tokenContext, Action.AUTHENTICATE)
// singleUseCheck, // TODO:hmlnarik - fix with single-use cache
}; };
} }
@ -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 { public static class ResetCredsAuthenticationProcessor extends AuthenticationProcessor {
@Override @Override

View file

@ -17,7 +17,6 @@
package org.keycloak.authentication.actiontoken.verifyemail; package org.keycloak.authentication.actiontoken.verifyemail;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.UUID;
import org.keycloak.authentication.actiontoken.DefaultActionToken; import org.keycloak.authentication.actiontoken.DefaultActionToken;
/** /**
@ -34,15 +33,12 @@ public class VerifyEmailActionToken extends DefaultActionToken {
@JsonProperty(value = JSON_FIELD_EMAIL) @JsonProperty(value = JSON_FIELD_EMAIL)
private String email; private String email;
public VerifyEmailActionToken(String userId, int absoluteExpirationInSecs, UUID actionVerificationNonce, String authenticationSessionId, public VerifyEmailActionToken(String userId, int absoluteExpirationInSecs, String authenticationSessionId, String email) {
String email) { super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null, authenticationSessionId);
super(userId, TOKEN_TYPE, absoluteExpirationInSecs, actionVerificationNonce);
setAuthenticationSessionId(authenticationSessionId);
this.email = email; this.email = email;
} }
private VerifyEmailActionToken() { private VerifyEmailActionToken() {
super(null, TOKEN_TYPE, -1, null);
} }
public String getEmail() { public String getEmail() {

View file

@ -108,7 +108,7 @@ public class IdpEmailVerificationAuthenticator extends AbstractIdpAuthenticator
UriInfo uriInfo = session.getContext().getUri(); UriInfo uriInfo = session.getContext().getUri();
AuthenticationSessionModel authSession = context.getAuthenticationSession(); AuthenticationSessionModel authSession = context.getAuthenticationSession();
int validityInSecs = realm.getAccessCodeLifespanUserAction(); int validityInSecs = realm.getActionTokenGeneratedByAdminLifespan();
int absoluteExpirationInSecs = Time.currentTime() + validityInSecs; int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;
EventBuilder event = context.getEvent().clone().event(EventType.SEND_IDENTITY_PROVIDER_LINK) EventBuilder event = context.getEvent().clone().event(EventType.SEND_IDENTITY_PROVIDER_LINK)
@ -120,7 +120,7 @@ public class IdpEmailVerificationAuthenticator extends AbstractIdpAuthenticator
.removeDetail(Details.AUTH_TYPE); .removeDetail(Details.AUTH_TYPE);
IdpVerifyAccountLinkActionToken token = new IdpVerifyAccountLinkActionToken( IdpVerifyAccountLinkActionToken token = new IdpVerifyAccountLinkActionToken(
existingUser.getId(), absoluteExpirationInSecs, null, authSession.getId(), existingUser.getId(), absoluteExpirationInSecs, authSession.getId(),
brokerContext.getUsername(), brokerContext.getIdpConfig().getAlias() brokerContext.getUsername(), brokerContext.getIdpConfig().getAlias()
); );
UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo)); UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo));

View file

@ -85,15 +85,11 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
return; return;
} }
int validityInSecs = context.getRealm().getAccessCodeLifespanUserAction(); int validityInSecs = context.getRealm().getActionTokenGeneratedByUserLifespan();
int absoluteExpirationInSecs = Time.currentTime() + validityInSecs; 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. // We send the secret in the email in a link as a query param.
ResetCredentialsActionToken token = new ResetCredentialsActionToken(user.getId(), absoluteExpirationInSecs, ResetCredentialsActionToken token = new ResetCredentialsActionToken(user.getId(), absoluteExpirationInSecs, authenticationSession.getId());
null, authenticationSession.getId(), lastCreatedPassword);
String link = UriBuilder String link = UriBuilder
.fromUri(context.getActionTokenUrl(token.serialize(context.getSession(), context.getRealm(), context.getUriInfo()))) .fromUri(context.getActionTokenUrl(token.serialize(context.getSession(), context.getRealm(), context.getUriInfo())))
.build() .build()
@ -101,6 +97,7 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs); long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs);
try { try {
context.getSession().getProvider(EmailTemplateProvider.class).setRealm(context.getRealm()).setUser(user).sendPasswordReset(link, expirationInMinutes); context.getSession().getProvider(EmailTemplateProvider.class).setRealm(context.getRealm()).setUser(user).sendPasswordReset(link, expirationInMinutes);
event.clone().event(EventType.SEND_RESET_PASSWORD) event.clone().event(EventType.SEND_RESET_PASSWORD)
.user(user) .user(user)
.detail(Details.USERNAME, username) .detail(Details.USERNAME, username)

View file

@ -157,4 +157,9 @@ public class UpdatePassword implements RequiredActionProvider, RequiredActionFac
public String getId() { public String getId() {
return UserModel.RequiredAction.UPDATE_PASSWORD.name(); return UserModel.RequiredAction.UPDATE_PASSWORD.name();
} }
@Override
public boolean isOneTimeAction() {
return true;
}
} }

View file

@ -118,4 +118,9 @@ public class UpdateTotp implements RequiredActionProvider, RequiredActionFactory
public String getId() { public String getId() {
return UserModel.RequiredAction.CONFIGURE_TOTP.name(); return UserModel.RequiredAction.CONFIGURE_TOTP.name();
} }
@Override
public boolean isOneTimeAction() {
return true;
}
} }

View file

@ -32,7 +32,6 @@ import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.*; import org.keycloak.models.*;
import org.keycloak.models.UserModel.RequiredAction;
import org.keycloak.services.Urls; import org.keycloak.services.Urls;
import org.keycloak.services.validation.Validation; import org.keycloak.services.validation.Validation;
@ -132,20 +131,20 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor
RealmModel realm = session.getContext().getRealm(); RealmModel realm = session.getContext().getRealm();
UriInfo uriInfo = session.getContext().getUri(); UriInfo uriInfo = session.getContext().getUri();
int validityInSecs = realm.getAccessCodeLifespanUserAction(); int validityInSecs = realm.getActionTokenGeneratedByUserLifespan();
int absoluteExpirationInSecs = Time.currentTime() + validityInSecs; 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)); UriBuilder builder = Urls.actionTokenBuilder(uriInfo.getBaseUri(), token.serialize(session, realm, uriInfo));
String link = builder.build(realm.getName()).toString(); String link = builder.build(realm.getName()).toString();
long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs); long expirationInMinutes = TimeUnit.SECONDS.toMinutes(validityInSecs);
try { 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(); event.success();
} catch (EmailException e) { } catch (EmailException e) {
logger.error("Failed to send verification email", e); logger.error("Failed to send verification email", e);

View file

@ -26,6 +26,7 @@ import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionContextResult; import org.keycloak.authentication.RequiredActionContextResult;
import org.keycloak.authentication.RequiredActionFactory; import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider; import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.authentication.actiontoken.DefaultActionTokenKey;
import org.keycloak.broker.provider.IdentityProvider; import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.common.ClientConnection; import org.keycloak.common.ClientConnection;
import org.keycloak.common.VerificationException; import org.keycloak.common.VerificationException;
@ -37,23 +38,10 @@ import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.jose.jws.AlgorithmType; import org.keycloak.jose.jws.AlgorithmType;
import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.*;
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.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocol.Error; import org.keycloak.protocol.LoginProtocol.Error;
import org.keycloak.protocol.RestartLoginCookie;
import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
import org.keycloak.services.ServicesLogger; import org.keycloak.services.ServicesLogger;
@ -77,11 +65,7 @@ import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo; import javax.ws.rs.core.UriInfo;
import java.net.URI; import java.net.URI;
import java.security.PublicKey; import java.security.PublicKey;
import java.util.Collection; import java.util.*;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
/** /**
* Stateless object that manages authentication * Stateless object that manages authentication
@ -92,6 +76,7 @@ import java.util.Set;
public class AuthenticationManager { public class AuthenticationManager {
public static final String SET_REDIRECT_URI_AFTER_REQUIRED_ACTIONS= "SET_REDIRECT_URI_AFTER_REQUIRED_ACTIONS"; 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 END_AFTER_REQUIRED_ACTIONS = "END_AFTER_REQUIRED_ACTIONS";
public static final String INVALIDATE_ACTION_TOKEN = "INVALIDATE_ACTION_TOKEN";
// Last authenticated client in userSession. // Last authenticated client in userSession.
public static final String LAST_AUTHENTICATED_CLIENT = "LAST_AUTHENTICATED_CLIENT"; 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, public static Response finishedRequiredActions(KeycloakSession session, AuthenticationSessionModel authSession, UserSessionModel userSession,
ClientConnection clientConnection, HttpRequest request, UriInfo uriInfo, EventBuilder event) { 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) { if (authSession.getAuthNote(END_AFTER_REQUIRED_ACTIONS) != null) {
LoginFormsProvider infoPage = session.getProvider(LoginFormsProvider.class) LoginFormsProvider infoPage = session.getProvider(LoginFormsProvider.class)
.setSuccess(Messages.ACCOUNT_UPDATED); .setSuccess(Messages.ACCOUNT_UPDATED);

View file

@ -504,8 +504,12 @@ public class LoginActionsService {
authSession = tokenContext.getAuthenticationSession(); authSession = tokenContext.getAuthenticationSession();
event = tokenContext.getEvent(); 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()); authSession.setAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID, token.getUserId());

View file

@ -304,4 +304,12 @@ public class LoginActionsServiceChecks {
return true; return true;
} }
public static <T extends DefaultActionToken> void checkTokenWasNotUsedYet(T token, ActionTokenContext<T> 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);
}
}
} }

View file

@ -816,7 +816,7 @@ public class UsersResource {
@QueryParam(OIDCLoginProtocol.CLIENT_ID_PARAM) String clientId) { @QueryParam(OIDCLoginProtocol.CLIENT_ID_PARAM) String clientId) {
List<String> actions = new LinkedList<>(); List<String> actions = new LinkedList<>();
actions.add(UserModel.RequiredAction.UPDATE_PASSWORD.name()); 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 id User is
* @param redirectUri Redirect uri * @param redirectUri Redirect uri
* @param clientId Client id * @param clientId Client id
* @param lifespan Number of seconds after which the generated token expires
* @param actions required actions the user needs to complete * @param actions required actions the user needs to complete
* @return * @return
*/ */
@ -840,6 +841,7 @@ public class UsersResource {
public Response executeActionsEmail(@PathParam("id") String id, public Response executeActionsEmail(@PathParam("id") String id,
@QueryParam(OIDCLoginProtocol.REDIRECT_URI_PARAM) String redirectUri, @QueryParam(OIDCLoginProtocol.REDIRECT_URI_PARAM) String redirectUri,
@QueryParam(OIDCLoginProtocol.CLIENT_ID_PARAM) String clientId, @QueryParam(OIDCLoginProtocol.CLIENT_ID_PARAM) String clientId,
@QueryParam("lifespan") Integer lifespan,
List<String> actions) { List<String> actions) {
auth.requireManage(); auth.requireManage();
@ -881,9 +883,11 @@ public class UsersResource {
} }
} }
long relativeExpiration = realm.getAccessCodeLifespanUserAction(); if (lifespan == null) {
int expiration = Time.currentTime() + realm.getAccessCodeLifespanUserAction(); lifespan = realm.getActionTokenGeneratedByAdminLifespan();
ExecuteActionsActionToken token = new ExecuteActionsActionToken(id, expiration, UUID.randomUUID(), actions, redirectUri, clientId); }
int expiration = Time.currentTime() + lifespan;
ExecuteActionsActionToken token = new ExecuteActionsActionToken(id, expiration, actions, redirectUri, clientId);
try { try {
UriBuilder builder = LoginActionsService.actionTokenProcessor(uriInfo); UriBuilder builder = LoginActionsService.actionTokenProcessor(uriInfo);
@ -894,7 +898,7 @@ public class UsersResource {
this.session.getProvider(EmailTemplateProvider.class) this.session.getProvider(EmailTemplateProvider.class)
.setRealm(realm) .setRealm(realm)
.setUser(user) .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(); //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) { public Response sendVerifyEmail(@PathParam("id") String id, @QueryParam(OIDCLoginProtocol.REDIRECT_URI_PARAM) String redirectUri, @QueryParam(OIDCLoginProtocol.CLIENT_ID_PARAM) String clientId) {
List<String> actions = new LinkedList<>(); List<String> actions = new LinkedList<>();
actions.add(UserModel.RequiredAction.VERIFY_EMAIL.name()); actions.add(UserModel.RequiredAction.VERIFY_EMAIL.name());
return executeActionsEmail(id, redirectUri, clientId, actions); return executeActionsEmail(id, redirectUri, clientId, null, actions);
} }
@GET @GET

View file

@ -358,8 +358,6 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
events.expectRequiredAction(EventType.VERIFY_EMAIL) events.expectRequiredAction(EventType.VERIFY_EMAIL)
.user(testUserId) .user(testUserId)
.detail(Details.USERNAME, "test-user@localhost")
.detail(Details.EMAIL, "test-user@localhost")
.detail(Details.CODE_ID, Matchers.not(Matchers.is(mailCodeId))) .detail(Details.CODE_ID, Matchers.not(Matchers.is(mailCodeId)))
.client(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID) // as authentication sessions are browser-specific, .client(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID) // as authentication sessions are browser-specific,
// the client and redirect_uri is unrelated to // the client and redirect_uri is unrelated to

View file

@ -45,6 +45,7 @@ import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.services.resources.RealmsResource; import org.keycloak.services.resources.RealmsResource;
import org.keycloak.testsuite.page.LoginPasswordUpdatePage; import org.keycloak.testsuite.page.LoginPasswordUpdatePage;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.InfoPage; import org.keycloak.testsuite.pages.InfoPage;
import org.keycloak.testsuite.pages.LoginPage; import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.util.AdminEventPaths; import org.keycloak.testsuite.util.AdminEventPaths;
@ -68,6 +69,7 @@ import java.util.Collections;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@ -95,6 +97,9 @@ public class UserTest extends AbstractAdminTest {
@Page @Page
protected InfoPage infoPage; protected InfoPage infoPage;
@Page
protected ErrorPage errorPage;
@Page @Page
protected LoginPage loginPage; protected LoginPage loginPage;
@ -546,8 +551,49 @@ public class UserTest extends AbstractAdminTest {
driver.navigate().to(link); 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<String> 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 @Test
@ -608,8 +654,7 @@ public class UserTest extends AbstractAdminTest {
driver.navigate().to(link); 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 @Test
@ -674,8 +719,7 @@ public class UserTest extends AbstractAdminTest {
driver.navigate().to(link); 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); driver.navigate().to(link);
Assert.assertEquals("Your account has been updated.", infoPage.getInfo()); 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 @Test

View file

@ -255,6 +255,8 @@ public class RealmTest extends AbstractAdminTest {
rep.setSsoSessionIdleTimeout(123); rep.setSsoSessionIdleTimeout(123);
rep.setSsoSessionMaxLifespan(12); rep.setSsoSessionMaxLifespan(12);
rep.setAccessCodeLifespanLogin(1234); rep.setAccessCodeLifespanLogin(1234);
rep.setActionTokenGeneratedByAdminLifespan(2345);
rep.setActionTokenGeneratedByUserLifespan(3456);
rep.setRegistrationAllowed(true); rep.setRegistrationAllowed(true);
rep.setRegistrationEmailAsUsername(true); rep.setRegistrationEmailAsUsername(true);
rep.setEditUsernameAllowed(true); rep.setEditUsernameAllowed(true);
@ -267,6 +269,8 @@ public class RealmTest extends AbstractAdminTest {
assertEquals(123, rep.getSsoSessionIdleTimeout().intValue()); assertEquals(123, rep.getSsoSessionIdleTimeout().intValue());
assertEquals(12, rep.getSsoSessionMaxLifespan().intValue()); assertEquals(12, rep.getSsoSessionMaxLifespan().intValue());
assertEquals(1234, rep.getAccessCodeLifespanLogin().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.isRegistrationAllowed());
assertEquals(Boolean.TRUE, rep.isRegistrationEmailAsUsername()); assertEquals(Boolean.TRUE, rep.isRegistrationEmailAsUsername());
assertEquals(Boolean.TRUE, rep.isEditUsernameAllowed()); 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.getAccessCodeLifespan() != null) assertEquals(realm.getAccessCodeLifespan(), storedRealm.getAccessCodeLifespan());
if (realm.getAccessCodeLifespanUserAction() != null) if (realm.getAccessCodeLifespanUserAction() != null)
assertEquals(realm.getAccessCodeLifespanUserAction(), storedRealm.getAccessCodeLifespanUserAction()); 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.getNotBefore() != null) assertEquals(realm.getNotBefore(), storedRealm.getNotBefore());
if (realm.getAccessTokenLifespan() != null) assertEquals(realm.getAccessTokenLifespan(), storedRealm.getAccessTokenLifespan()); if (realm.getAccessTokenLifespan() != null) assertEquals(realm.getAccessTokenLifespan(), storedRealm.getAccessTokenLifespan());
if (realm.getAccessTokenLifespanForImplicitFlow() != null) assertEquals(realm.getAccessTokenLifespanForImplicitFlow(), storedRealm.getAccessTokenLifespanForImplicitFlow()); if (realm.getAccessTokenLifespanForImplicitFlow() != null) assertEquals(realm.getAccessTokenLifespanForImplicitFlow(), storedRealm.getAccessTokenLifespanForImplicitFlow());

View file

@ -189,19 +189,17 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
} }
public void assertSecondPasswordResetFails(String changePasswordUrl, String clientId) { public void assertSecondPasswordResetFails(String changePasswordUrl, String clientId) {
// TODO:hmlnarik uncomment when single-use cache is implemented driver.navigate().to(changePasswordUrl.trim());
// driver.navigate().to(changePasswordUrl.trim());
// errorPage.assertCurrent();
// errorPage.assertCurrent(); assertEquals("Action expired. Please continue with login now.", errorPage.getError());
// assertEquals("An error occurred, please login again through your application.", errorPage.getError());
// events.expect(EventType.RESET_PASSWORD)
// events.expect(EventType.RESET_PASSWORD) .client("account")
// .client((String) null) .session((String) null)
// .session((String) null) .user(userId)
// .user(userId) .error(Errors.EXPIRED_CODE)
// .detail(Details.USERNAME, "login-test") .assertEvent();
// .error(Errors.EXPIRED_CODE)
// .assertEvent();
} }
@Test @Test
@ -386,8 +384,8 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
final AtomicInteger originalValue = new AtomicInteger(); final AtomicInteger originalValue = new AtomicInteger();
RealmRepresentation realmRep = testRealm().toRepresentation(); RealmRepresentation realmRep = testRealm().toRepresentation();
originalValue.set(realmRep.getAccessCodeLifespan()); originalValue.set(realmRep.getActionTokenGeneratedByUserLifespan());
realmRep.setAccessCodeLifespanUserAction(60); realmRep.setActionTokenGeneratedByUserLifespan(60);
testRealm().update(realmRep); testRealm().update(realmRep);
try { try {
@ -415,7 +413,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
} finally { } finally {
setTimeOffset(0); setTimeOffset(0);
realmRep.setAccessCodeLifespanUserAction(originalValue.get()); realmRep.setActionTokenGeneratedByUserLifespan(originalValue.get());
testRealm().update(realmRep); testRealm().update(realmRep);
} }
} }

View file

@ -9,6 +9,8 @@
"publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", "publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB",
"requiredCredentials": [ "password" ], "requiredCredentials": [ "password" ],
"defaultRoles": [ "user" ], "defaultRoles": [ "user" ],
"actionTokenGeneratedByAdminLifespan": "147",
"actionTokenGeneratedByUserLifespan": "258",
"smtpServer": { "smtpServer": {
"from": "auto@keycloak.org", "from": "auto@keycloak.org",
"host": "localhost", "host": "localhost",

View file

@ -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.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=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'. 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=Client login timeout
client-login-timeout.tooltip=Max time an client has to finish the access token protocol. This should normally be 1 minute. 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 login-timeout=Login timeout
@ -1292,6 +1296,8 @@ credential-types=Credential Types
manage-user-password=Manage Password manage-user-password=Manage Password
disable-credentials=Disable Credentials disable-credentials=Disable Credentials
credential-reset-actions=Credential Reset 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 ldap-mappers=LDAP Mappers
create-ldap-mapper=Create LDAP mapper create-ldap-mapper=Create LDAP mapper

View file

@ -1044,6 +1044,8 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http,
$scope.realm.accessCodeLifespan = TimeUnit2.asUnit(realm.accessCodeLifespan); $scope.realm.accessCodeLifespan = TimeUnit2.asUnit(realm.accessCodeLifespan);
$scope.realm.accessCodeLifespanLogin = TimeUnit2.asUnit(realm.accessCodeLifespanLogin); $scope.realm.accessCodeLifespanLogin = TimeUnit2.asUnit(realm.accessCodeLifespanLogin);
$scope.realm.accessCodeLifespanUserAction = TimeUnit2.asUnit(realm.accessCodeLifespanUserAction); $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); var oldCopy = angular.copy($scope.realm);
$scope.changed = false; $scope.changed = false;
@ -1063,6 +1065,8 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http,
$scope.realm.accessCodeLifespan = $scope.realm.accessCodeLifespan.toSeconds(); $scope.realm.accessCodeLifespan = $scope.realm.accessCodeLifespan.toSeconds();
$scope.realm.accessCodeLifespanUserAction = $scope.realm.accessCodeLifespanUserAction.toSeconds(); $scope.realm.accessCodeLifespanUserAction = $scope.realm.accessCodeLifespanUserAction.toSeconds();
$scope.realm.accessCodeLifespanLogin = $scope.realm.accessCodeLifespanLogin.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 () { Realm.update($scope.realm, function () {
$route.reload(); $route.reload();

View file

@ -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'); console.log('UserCredentialsCtrl');
$scope.realm = realm; $scope.realm = realm;
@ -548,6 +548,7 @@ module.controller('UserCredentialsCtrl', function($scope, realm, user, $route, R
}; };
$scope.emailActions = []; $scope.emailActions = [];
$scope.emailActionsTimeout = TimeUnit2.asUnit(realm.actionTokenGeneratedByAdminLifespan);
$scope.disableableCredentialTypes = []; $scope.disableableCredentialTypes = [];
$scope.sendExecuteActionsEmail = function() { $scope.sendExecuteActionsEmail = function() {
@ -556,7 +557,7 @@ module.controller('UserCredentialsCtrl', function($scope, realm, user, $route, R
return; return;
} }
Dialog.confirm('Send Email', 'Are you sure you want to send email to user?', function() { 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"); Notifications.success("Email sent to user");
$scope.emailActions = []; $scope.emailActions = [];
}, function() { }, function() {

View file

@ -496,7 +496,8 @@ module.factory('UserCredentials', function($resource) {
module.factory('UserExecuteActionsEmail', function($resource) { module.factory('UserExecuteActionsEmail', function($resource) {
return $resource(authUrl + '/admin/realms/:realm/users/:userId/execute-actions-email', { return $resource(authUrl + '/admin/realms/:realm/users/:userId/execute-actions-email', {
realm : '@realm', realm : '@realm',
userId : '@userId' userId : '@userId',
lifespan : '@lifespan',
}, { }, {
update : { update : {
method : 'PUT' method : 'PUT'

View file

@ -141,6 +141,40 @@
</kc-tooltip> </kc-tooltip>
</div> </div>
<div class="form-group">
<label class="col-md-2 control-label" for="actionTokenGeneratedByUserLifespan" class="two-lines">{{:: 'action-token-generated-by-user-lifespan' | translate}}</label>
<div class="col-md-6 time-selector">
<input class="form-control" type="number" required min="1" max="31536000" data-ng-model="realm.actionTokenGeneratedByUserLifespan.time"
id="actionTokenGeneratedByUserLifespan" name="actionTokenGeneratedByUserLifespan">
<select class="form-control" name="actionTokenGeneratedByUserLifespanUnit" data-ng-model="realm.actionTokenGeneratedByUserLifespan.unit">
<option value="Minutes">{{:: 'minutes' | translate}}</option>
<option value="Hours">{{:: 'hours' | translate}}</option>
<option value="Days">{{:: 'days' | translate}}</option>
</select>
</div>
<kc-tooltip>
{{:: 'action-token-generated-by-user-lifespan.tooltip' | translate}}
</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="actionTokenGeneratedByAdminLifespan" class="two-lines">{{:: 'action-token-generated-by-admin-lifespan' | translate}}</label>
<div class="col-md-6 time-selector">
<input class="form-control" type="number" required min="1" max="31536000" data-ng-model="realm.actionTokenGeneratedByAdminLifespan.time"
id="actionTokenGeneratedByAdminLifespan" name="actionTokenGeneratedByAdminLifespan">
<select class="form-control" name="actionTokenGeneratedByAdminLifespanUnit" data-ng-model="realm.actionTokenGeneratedByAdminLifespan.unit">
<option value="Minutes">{{:: 'minutes' | translate}}</option>
<option value="Hours">{{:: 'hours' | translate}}</option>
<option value="Days">{{:: 'days' | translate}}</option>
</select>
</div>
<kc-tooltip>
{{:: 'action-token-generated-by-admin-lifespan.tooltip' | translate}}
</kc-tooltip>
</div>
<div class="form-group"> <div class="form-group">
<div class="col-md-10 col-md-offset-2" data-ng-show="access.manageRealm"> <div class="col-md-10 col-md-offset-2" data-ng-show="access.manageRealm">
<button kc-save data-ng-disabled="!changed">{{:: 'save' | translate}}</button> <button kc-save data-ng-disabled="!changed">{{:: 'save' | translate}}</button>

View file

@ -75,6 +75,20 @@
</div> </div>
<kc-tooltip>{{:: 'credentials.reset-actions.tooltip' | translate}}</kc-tooltip> <kc-tooltip>{{:: 'credentials.reset-actions.tooltip' | translate}}</kc-tooltip>
</div> </div>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="reqActionsEmailTimeout">{{:: 'credential-reset-actions-timeout' | translate}}</label>
<div class="col-md-6 time-selector">
<input class="form-control" type="number" required min="1" max="31536000" data-ng-model="emailActionsTimeout.time"
id="reqActionsEmailTimeout" name="reqActionsEmailTimeout">
<select class="form-control" name="reqActionsEmailTimeoutUnit" data-ng-model="emailActionsTimeout.unit">
<option value="Minutes">{{:: 'minutes' | translate}}</option>
<option value="Hours">{{:: 'hours' | translate}}</option>
<option value="Days">{{:: 'days' | translate}}</option>
</select>
</div>
<kc-tooltip>{{:: 'credential-reset-actions-timeout.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix"> <div class="form-group clearfix">
<label class="col-md-2 control-label" for="reqActionsEmail">{{:: 'reset-actions-email' | translate}}</label> <label class="col-md-2 control-label" for="reqActionsEmail">{{:: 'reset-actions-email' | translate}}</label>

View file

@ -41,12 +41,12 @@ import java.util.List;
public class KeycloakServerDeploymentProcessor implements DeploymentUnitProcessor { public class KeycloakServerDeploymentProcessor implements DeploymentUnitProcessor {
private static final String[] CACHES = new String[] { 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 // This param name is defined again in Keycloak Services class
// org.keycloak.services.resources.KeycloakApplication. We have this value in // 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"; public static final String KEYCLOAK_CONFIG_PARAM_NAME = "org.keycloak.server-subsystem.Config";
@Override @Override

View file

@ -43,6 +43,10 @@
<eviction max-entries="1000" strategy="LRU"/> <eviction max-entries="1000" strategy="LRU"/>
<expiration max-idle="3600000" /> <expiration max-idle="3600000" />
</local-cache> </local-cache>
<local-cache name="actionTokens">
<eviction max-entries="-1" strategy="NONE"/>
<expiration max-idle="-1" interval="300000"/>
</local-cache>
</cache-container> </cache-container>
<cache-container name="server" default-cache="default" module="org.wildfly.clustering.server"> <cache-container name="server" default-cache="default" module="org.wildfly.clustering.server">
<local-cache name="default"> <local-cache name="default">
@ -109,6 +113,10 @@
<eviction max-entries="1000" strategy="LRU"/> <eviction max-entries="1000" strategy="LRU"/>
<expiration max-idle="3600000" /> <expiration max-idle="3600000" />
</local-cache> </local-cache>
<local-cache name="actionTokens">
<eviction max-entries="-1" strategy="NONE"/>
<expiration max-idle="-1" interval="300000"/>
</local-cache>
</cache-container> </cache-container>
<cache-container name="server" aliases="singleton cluster" default-cache="default" module="org.wildfly.clustering.server"> <cache-container name="server" aliases="singleton cluster" default-cache="default" module="org.wildfly.clustering.server">
<transport lock-timeout="60000"/> <transport lock-timeout="60000"/>

View file

@ -43,6 +43,10 @@
<eviction max-entries="1000" strategy="LRU"/> <eviction max-entries="1000" strategy="LRU"/>
<expiration max-idle="3600000" /> <expiration max-idle="3600000" />
</local-cache> </local-cache>
<local-cache name="actionTokens">
<eviction max-entries="-1" strategy="NONE"/>
<expiration max-idle="-1" interval="300000"/>
</local-cache>
</cache-container> </cache-container>
<cache-container name="server" default-cache="default" module="org.wildfly.clustering.server"> <cache-container name="server" default-cache="default" module="org.wildfly.clustering.server">
<local-cache name="default"> <local-cache name="default">
@ -112,6 +116,10 @@
<eviction max-entries="1000" strategy="LRU"/> <eviction max-entries="1000" strategy="LRU"/>
<expiration max-idle="3600000" /> <expiration max-idle="3600000" />
</local-cache> </local-cache>
<local-cache name="actionTokens">
<eviction max-entries="-1" strategy="NONE"/>
<expiration max-idle="-1" interval="300000"/>
</local-cache>
</cache-container> </cache-container>
<cache-container name="server" aliases="singleton cluster" default-cache="default" module="org.wildfly.clustering.server"> <cache-container name="server" aliases="singleton cluster" default-cache="default" module="org.wildfly.clustering.server">
<transport lock-timeout="60000"/> <transport lock-timeout="60000"/>