keycloak-aplcache

Merge pull request #4676 from abstractj/KEYCLOAK-2052 [KEYCLOAK-2052]

11/14/2017 6:19:57 AM

Changes

Details

diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java
index 52a81de..e854002 100755
--- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java
+++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java
@@ -143,6 +143,8 @@ public class CachedRealm extends AbstractExtendableRevisioned {
 
     protected Map<String, String> attributes;
 
+    private Map<String, Integer> userActionTokenLifespans;
+
     public CachedRealm(Long revision, RealmModel model) {
         super(revision, model.getId());
         name = model.getName();
@@ -192,6 +194,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
         emailTheme = model.getEmailTheme();
 
         requiredCredentials = model.getRequiredCredentials();
+        userActionTokenLifespans = Collections.unmodifiableMap(new HashMap<>(model.getUserActionTokenLifespans()));
 
         this.identityProviders = new ArrayList<>();
 
@@ -407,6 +410,11 @@ public class CachedRealm extends AbstractExtendableRevisioned {
     public int getAccessCodeLifespanUserAction() {
         return accessCodeLifespanUserAction;
     }
+
+    public Map<String, Integer> getUserActionTokenLifespans() {
+        return userActionTokenLifespans;
+    }
+
     public int getAccessCodeLifespanLogin() {
         return accessCodeLifespanLogin;
     }
@@ -419,6 +427,18 @@ public class CachedRealm extends AbstractExtendableRevisioned {
         return actionTokenGeneratedByUserLifespan;
     }
 
+    /**
+     * This method is supposed to return user lifespan based on the action token ID
+     * provided. If nothing is provided, it will return the default lifespan.
+     * @param actionTokenId
+     * @return lifespan
+     */
+    public int getActionTokenGeneratedByUserLifespan(String actionTokenId) {
+        if (actionTokenId == null || this.userActionTokenLifespans.get(actionTokenId) == null)
+            return getActionTokenGeneratedByUserLifespan();
+        return this.userActionTokenLifespans.get(actionTokenId);
+    }
+
     public List<RequiredCredentialModel> getRequiredCredentials() {
         return requiredCredentials;
     }
@@ -609,5 +629,4 @@ public class CachedRealm extends AbstractExtendableRevisioned {
     public Map<String, String> getAttributes() {
         return attributes;
     }
-
 }
diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java
index f3ae325..bfa00e0 100755
--- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java
+++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java
@@ -455,6 +455,12 @@ public class RealmAdapter implements CachedRealmModel {
     }
 
     @Override
+    public Map<String, Integer> getUserActionTokenLifespans() {
+        if (isUpdated()) return updated.getUserActionTokenLifespans();
+        return cached.getUserActionTokenLifespans();
+    }
+
+    @Override
     public int getAccessCodeLifespanLogin() {
         if (isUpdated()) return updated.getAccessCodeLifespanLogin();
         return cached.getAccessCodeLifespanLogin();
@@ -491,6 +497,20 @@ public class RealmAdapter implements CachedRealmModel {
     }
 
     @Override
+    public int getActionTokenGeneratedByUserLifespan(String actionTokenId) {
+        if (isUpdated()) return updated.getActionTokenGeneratedByUserLifespan(actionTokenId);
+        return cached.getActionTokenGeneratedByUserLifespan(actionTokenId);
+    }
+
+    @Override
+    public void setActionTokenGeneratedByUserLifespan(String actionTokenId, Integer seconds) {
+        if (seconds != null) {
+            getDelegateForUpdate();
+            updated.setActionTokenGeneratedByUserLifespan(actionTokenId, seconds);
+        }
+    }
+
+    @Override
     public List<RequiredCredentialModel> getRequiredCredentials() {
         if (isUpdated()) return updated.getRequiredCredentials();
         return cached.getRequiredCredentials();
diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java
index 862db6c..d82144d 100755
--- a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java
+++ b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java
@@ -474,6 +474,19 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
     }
 
     @Override
+    public Map<String, Integer> getUserActionTokenLifespans() {
+
+        Map<String, Integer> userActionTokens = new HashMap<>();
+
+        getAttributes().entrySet().stream()
+                .filter(Objects::nonNull)
+                .filter(entry -> entry.getKey().startsWith(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN + "."))
+                .forEach(entry -> userActionTokens.put(entry.getKey().substring(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN.length() + 1), Integer.valueOf(entry.getValue())));
+
+        return Collections.unmodifiableMap(userActionTokens);
+    }
+
+    @Override
     public int getAccessCodeLifespanLogin() {
         return realm.getAccessCodeLifespanLogin();
     }
@@ -504,6 +517,17 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
         setAttribute(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN, actionTokenGeneratedByUserLifespan);
     }
 
+    @Override
+    public int getActionTokenGeneratedByUserLifespan(String actionTokenId) {
+        return getAttribute(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN + "." + actionTokenId, getAccessCodeLifespanUserAction());
+    }
+
+    @Override
+    public void setActionTokenGeneratedByUserLifespan(String actionTokenId, Integer actionTokenGeneratedByUserLifespan) {
+        if (actionTokenGeneratedByUserLifespan != null)
+            setAttribute(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN + "." + actionTokenId, actionTokenGeneratedByUserLifespan);
+    }
+
     protected RequiredCredentialModel initRequiredCredentialModel(String type) {
         RequiredCredentialModel model = RequiredCredentialModel.BUILT_IN.get(type);
         if (model == null) {
@@ -647,7 +671,7 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
             entities.remove(entity);
         }
         em.flush();
-     }
+    }
 
     @Override
     public List<GroupModel> getDefaultGroups() {
@@ -1802,7 +1826,7 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
 
     /**
      * This just exists for testing purposes
-     *
+     * 
      */
     public static final String COMPONENT_PROVIDER_EXISTS_DISABLED = "component.provider.exists.disabled";
 
@@ -1954,4 +1978,5 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
         if (c == null) return null;
         return entityToModel(c);
     }
+
 }
\ No newline at end of file
diff --git a/server-spi/src/main/java/org/keycloak/models/RealmModel.java b/server-spi/src/main/java/org/keycloak/models/RealmModel.java
index 4d7090e..6d48425 100755
--- a/server-spi/src/main/java/org/keycloak/models/RealmModel.java
+++ b/server-spi/src/main/java/org/keycloak/models/RealmModel.java
@@ -186,6 +186,13 @@ public interface RealmModel extends RoleContainerModel {
 
     void setAccessCodeLifespanUserAction(int seconds);
 
+    /**
+     * This method will return a map with all the lifespans available
+     * or an empty map, but never null.
+     * @return map with user action token lifespans
+     */
+    Map<String, Integer> getUserActionTokenLifespans();
+
     int getAccessCodeLifespanLogin();
 
     void setAccessCodeLifespanLogin(int seconds);
@@ -196,6 +203,9 @@ public interface RealmModel extends RoleContainerModel {
     int getActionTokenGeneratedByUserLifespan();
     void setActionTokenGeneratedByUserLifespan(int seconds);
 
+    int getActionTokenGeneratedByUserLifespan(String actionTokenType);
+    void setActionTokenGeneratedByUserLifespan(String actionTokenType, Integer seconds);
+
     List<RequiredCredentialModel> getRequiredCredentials();
 
     void addRequiredCredential(String cred);
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java
index d118634..093fbb7 100755
--- a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpEmailVerificationAuthenticator.java
@@ -116,7 +116,7 @@ public class IdpEmailVerificationAuthenticator extends AbstractIdpAuthenticator 
         UriInfo uriInfo = session.getContext().getUri();
         AuthenticationSessionModel authSession = context.getAuthenticationSession();
 
-        int validityInSecs = realm.getActionTokenGeneratedByUserLifespan();
+        int validityInSecs = realm.getActionTokenGeneratedByUserLifespan(IdpVerifyAccountLinkActionToken.TOKEN_TYPE);
         int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;
 
         EventBuilder event = context.getEvent().clone().event(EventType.SEND_IDENTITY_PROVIDER_LINK)
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java
index 4ac9bff..a61d4db 100755
--- a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialEmail.java
@@ -85,7 +85,7 @@ public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory
             return;
         }
 
-        int validityInSecs = context.getRealm().getActionTokenGeneratedByUserLifespan();
+        int validityInSecs = context.getRealm().getActionTokenGeneratedByUserLifespan(ResetCredentialsActionToken.TOKEN_TYPE);
         int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;
 
         // We send the secret in the email in a link as a query param.
diff --git a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java
index baa3c4e..4208308 100755
--- a/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java
+++ b/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java
@@ -131,7 +131,7 @@ public class VerifyEmail implements RequiredActionProvider, RequiredActionFactor
         RealmModel realm = session.getContext().getRealm();
         UriInfo uriInfo = session.getContext().getUri();
 
-        int validityInSecs = realm.getActionTokenGeneratedByUserLifespan();
+        int validityInSecs = realm.getActionTokenGeneratedByUserLifespan(VerifyEmailActionToken.TOKEN_TYPE);
         int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;
 
         VerifyEmailActionToken token = new VerifyEmailActionToken(user.getId(), absoluteExpirationInSecs, authSession.getId(), user.getEmail());
diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/MailUtils.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/MailUtils.java
index 69aa142..add2a89 100644
--- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/MailUtils.java
+++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/MailUtils.java
@@ -56,7 +56,11 @@ public class MailUtils {
         assertEquals("text/html; charset=UTF-8", htmlContentType);
 
         final String htmlBody = (String) multipart.getBodyPart(1).getContent();
-        final String htmlChangePwdUrl = getLink(htmlBody);
+        // .replace() accounts for escaping the ampersand
+        // It's not escaped in the html version because html retrieved from a
+        // message bundle is considered safe and it must be unescaped to display
+        // properly.
+        final String htmlChangePwdUrl = MailUtils.getLink(htmlBody).replace("&", "&amp;");
 
         assertEquals(htmlChangePwdUrl, textChangePwdUrl);
 
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionEmailVerificationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionEmailVerificationTest.java
index f4c7452..55dd453 100755
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionEmailVerificationTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/RequiredActionEmailVerificationTest.java
@@ -27,14 +27,12 @@ import org.keycloak.events.Details;
 import org.keycloak.events.Errors;
 import org.keycloak.events.EventType;
 import org.keycloak.models.Constants;
-import org.keycloak.models.UserModel;
 import org.keycloak.representations.idm.EventRepresentation;
 import org.keycloak.representations.idm.RealmRepresentation;
 import org.keycloak.representations.idm.UserRepresentation;
 import org.keycloak.testsuite.AssertEvents;
 import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
 import org.keycloak.testsuite.admin.ApiUtil;
-import org.keycloak.testsuite.auth.page.AuthRealm;
 import org.keycloak.testsuite.pages.AppPage;
 import org.keycloak.testsuite.pages.AppPage.RequestType;
 import org.keycloak.testsuite.pages.ProceedPage;
@@ -45,16 +43,19 @@ import org.keycloak.testsuite.pages.RegisterPage;
 import org.keycloak.testsuite.pages.VerifyEmailPage;
 import org.keycloak.testsuite.util.GreenMailRule;
 import org.keycloak.testsuite.util.MailUtils;
+import org.keycloak.testsuite.util.UserActionTokenBuilder;
 import org.keycloak.testsuite.util.UserBuilder;
 
 import javax.mail.MessagingException;
 import javax.mail.Multipart;
 import javax.mail.internet.MimeMessage;
 import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
 
 import org.hamcrest.Matchers;
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
 
 /**
  * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@@ -449,6 +450,95 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo
     }
 
     @Test
+    public void verifyEmailExpiredCodedPerActionLifespan() throws IOException, MessagingException {
+        RealmRepresentation realmRep = testRealm().toRepresentation();
+        Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
+
+        realmRep.setAttributes(UserActionTokenBuilder.create().verifyEmailLifespan(60).build());
+        testRealm().update(realmRep);
+
+        loginPage.open();
+        loginPage.login("test-user@localhost", "password");
+
+        verifyEmailPage.assertCurrent();
+
+        Assert.assertEquals(1, greenMail.getReceivedMessages().length);
+
+        MimeMessage message = greenMail.getLastReceivedMessage();
+
+        String verificationUrl = getPasswordResetEmailLink(message);
+
+        events.poll();
+
+        try {
+            setTimeOffset(70);
+
+            driver.navigate().to(verificationUrl.trim());
+
+            loginPage.assertCurrent();
+            assertEquals("Action expired. Please start again.", loginPage.getError());
+
+            events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR)
+                    .error(Errors.EXPIRED_CODE)
+                    .client((String)null)
+                    .user(testUserId)
+                    .session((String)null)
+                    .clearDetails()
+                    .detail(Details.ACTION, VerifyEmailActionToken.TOKEN_TYPE)
+                    .assertEvent();
+        } finally {
+            setTimeOffset(0);
+            realmRep.setAttributes(originalAttributes);
+            testRealm().update(realmRep);
+        }
+    }
+
+    @Test
+    public void verifyEmailExpiredCodedPerActionMultipleTimeouts() throws IOException, MessagingException {
+        RealmRepresentation realmRep = testRealm().toRepresentation();
+        Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
+
+        //Make sure that one attribute settings won't affect the other
+        realmRep.setAttributes(UserActionTokenBuilder.create().verifyEmailLifespan(60).resetCredentialsLifespan(300).build());
+        testRealm().update(realmRep);
+
+        loginPage.open();
+        loginPage.login("test-user@localhost", "password");
+
+        verifyEmailPage.assertCurrent();
+
+        Assert.assertEquals(1, greenMail.getReceivedMessages().length);
+
+        MimeMessage message = greenMail.getLastReceivedMessage();
+
+        String verificationUrl = getPasswordResetEmailLink(message);
+
+        events.poll();
+
+        try {
+            setTimeOffset(70);
+
+            driver.navigate().to(verificationUrl.trim());
+
+            loginPage.assertCurrent();
+            assertEquals("Action expired. Please start again.", loginPage.getError());
+
+            events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR)
+                    .error(Errors.EXPIRED_CODE)
+                    .client((String)null)
+                    .user(testUserId)
+                    .session((String)null)
+                    .clearDetails()
+                    .detail(Details.ACTION, VerifyEmailActionToken.TOKEN_TYPE)
+                    .assertEvent();
+        } finally {
+            setTimeOffset(0);
+            realmRep.setAttributes(originalAttributes);
+            testRealm().update(realmRep);
+        }
+    }
+
+    @Test
     public void verifyEmailExpiredCodeAndExpiredSession() throws IOException, MessagingException {
         loginPage.open();
         loginPage.login("test-user@localhost", "password");
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BrowserButtonsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BrowserButtonsTest.java
index da54a72..15477fe 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BrowserButtonsTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BrowserButtonsTest.java
@@ -47,6 +47,7 @@ import org.keycloak.testsuite.pages.OAuthGrantPage;
 import org.keycloak.testsuite.pages.RegisterPage;
 import org.keycloak.testsuite.pages.VerifyEmailPage;
 import org.keycloak.testsuite.util.GreenMailRule;
+import org.keycloak.testsuite.util.MailUtils;
 import org.keycloak.testsuite.util.OAuthClient;
 import org.keycloak.testsuite.util.UserBuilder;
 
@@ -337,7 +338,7 @@ public class BrowserButtonsTest extends AbstractTestRealmKeycloakTest {
         // Receive email
         MimeMessage message = greenMail.getReceivedMessages()[greenMail.getReceivedMessages().length - 1];
 
-        String changePasswordUrl = ResetPasswordTest.getPasswordResetEmailLink(message);
+        String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
 
         driver.navigate().to(changePasswordUrl.trim());
 
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java
index bc4379e..a009820 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ResetPasswordTest.java
@@ -38,12 +38,13 @@ import org.keycloak.testsuite.pages.VerifyEmailPage;
 import org.keycloak.testsuite.util.GreenMailRule;
 import org.keycloak.testsuite.util.MailUtils;
 import org.keycloak.testsuite.util.OAuthClient;
+import org.keycloak.testsuite.util.UserActionTokenBuilder;
 import org.keycloak.testsuite.util.UserBuilder;
 
 import javax.mail.MessagingException;
-import javax.mail.Multipart;
 import javax.mail.internet.MimeMessage;
 import java.io.IOException;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -131,7 +132,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
 
         MimeMessage message = greenMail.getReceivedMessages()[0];
 
-        String changePasswordUrl = getPasswordResetEmailLink(message);
+        String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
 
         driver.navigate().to(changePasswordUrl.trim());
 
@@ -251,7 +252,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
 
         MimeMessage message = greenMail.getReceivedMessages()[greenMail.getReceivedMessages().length - 1];
 
-        String changePasswordUrl = getPasswordResetEmailLink(message);
+        String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
 
         driver.navigate().to(changePasswordUrl.trim());
 
@@ -295,7 +296,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
 
         MimeMessage message = greenMail.getReceivedMessages()[greenMail.getReceivedMessages().length - 1];
 
-        String changePasswordUrl = getPasswordResetEmailLink(message);
+        String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
 
         driver.navigate().to(changePasswordUrl.trim());
 
@@ -362,7 +363,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
 
         MimeMessage message = greenMail.getReceivedMessages()[0];
 
-        String changePasswordUrl = getPasswordResetEmailLink(message);
+        String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
 
         try {
             setTimeOffset(1800 + 23);
@@ -399,7 +400,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
 
             MimeMessage message = greenMail.getReceivedMessages()[0];
 
-            String changePasswordUrl = getPasswordResetEmailLink(message);
+            String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
 
             setTimeOffset(70);
 
@@ -418,6 +419,84 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
         }
     }
 
+    @Test
+    public void resetPasswordExpiredCodeShortPerActionLifespan() throws IOException, MessagingException, InterruptedException {
+        RealmRepresentation realmRep = testRealm().toRepresentation();
+        Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
+
+        realmRep.setAttributes(UserActionTokenBuilder.create().resetCredentialsLifespan(60).build());
+        testRealm().update(realmRep);
+
+        try {
+            initiateResetPasswordFromResetPasswordPage("login-test");
+
+            events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
+                    .session((String)null)
+                    .user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
+
+            assertEquals(1, greenMail.getReceivedMessages().length);
+
+            MimeMessage message = greenMail.getReceivedMessages()[0];
+
+            String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
+
+            setTimeOffset(70);
+
+            driver.navigate().to(changePasswordUrl.trim());
+
+            loginPage.assertCurrent();
+
+            assertEquals("Action expired. Please start again.", loginPage.getError());
+
+            events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR).error("expired_code").client((String) null).user(userId).session((String) null).clearDetails().detail(Details.ACTION, ResetCredentialsActionToken.TOKEN_TYPE).assertEvent();
+        } finally {
+            setTimeOffset(0);
+
+            realmRep.setAttributes(originalAttributes);
+            testRealm().update(realmRep);
+        }
+    }
+
+    @Test
+    public void resetPasswordExpiredCodeShortPerActionMultipleTimeouts() throws IOException, MessagingException, InterruptedException {
+        RealmRepresentation realmRep = testRealm().toRepresentation();
+        Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
+
+        //Make sure that one attribute settings won't affect the other
+        realmRep.setAttributes(UserActionTokenBuilder.create().resetCredentialsLifespan(60).verifyEmailLifespan(300).build());
+
+        testRealm().update(realmRep);
+
+        try {
+            initiateResetPasswordFromResetPasswordPage("login-test");
+
+            events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
+                    .session((String)null)
+                    .user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
+
+            assertEquals(1, greenMail.getReceivedMessages().length);
+
+            MimeMessage message = greenMail.getReceivedMessages()[0];
+
+            String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
+
+            setTimeOffset(70);
+
+            driver.navigate().to(changePasswordUrl.trim());
+
+            loginPage.assertCurrent();
+
+            assertEquals("Action expired. Please start again.", loginPage.getError());
+
+            events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR).error("expired_code").client((String) null).user(userId).session((String) null).clearDetails().detail(Details.ACTION, ResetCredentialsActionToken.TOKEN_TYPE).assertEvent();
+        } finally {
+            setTimeOffset(0);
+
+            realmRep.setAttributes(originalAttributes);
+            testRealm().update(realmRep);
+        }
+    }
+
     // KEYCLOAK-4016
     @Test
     public void resetPasswordExpiredCodeAndAuthSession() throws IOException, MessagingException, InterruptedException {
@@ -439,7 +518,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
 
             MimeMessage message = greenMail.getReceivedMessages()[0];
 
-            String changePasswordUrl = getPasswordResetEmailLink(message).replace("&amp;", "&");
+            String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message).replace("&amp;", "&");
 
             setTimeOffset(70);
 
@@ -463,6 +542,92 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
         }
     }
 
+    @Test
+    public void resetPasswordExpiredCodeAndAuthSessionPerActionLifespan() throws IOException, MessagingException, InterruptedException {
+        RealmRepresentation realmRep = testRealm().toRepresentation();
+        Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
+
+        realmRep.setAttributes(UserActionTokenBuilder.create().resetCredentialsLifespan(60).build());
+        testRealm().update(realmRep);
+
+        try {
+            initiateResetPasswordFromResetPasswordPage("login-test");
+
+            events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
+                    .session((String)null)
+                    .user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
+
+            assertEquals(1, greenMail.getReceivedMessages().length);
+
+            MimeMessage message = greenMail.getReceivedMessages()[0];
+
+            String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message).replace("&amp;", "&");
+
+            setTimeOffset(70);
+
+            log.debug("Going to reset password URI.");
+            driver.navigate().to(oauth.AUTH_SERVER_ROOT + "/realms/test/login-actions/reset-credentials"); // This is necessary to delete KC_RESTART cookie that is restricted to /auth/realms/test path
+            log.debug("Removing cookies.");
+            driver.manage().deleteAllCookies();
+            driver.navigate().to(changePasswordUrl.trim());
+
+            errorPage.assertCurrent();
+            Assert.assertEquals("Action expired.", errorPage.getError());
+            String backToAppLink = errorPage.getBackToApplicationLink();
+            Assert.assertTrue(backToAppLink.endsWith("/app/auth"));
+
+            events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR).error("expired_code").client((String) null).user(userId).session((String) null).clearDetails().detail(Details.ACTION, ResetCredentialsActionToken.TOKEN_TYPE).assertEvent();
+        } finally {
+            setTimeOffset(0);
+
+            realmRep.setAttributes(originalAttributes);
+            testRealm().update(realmRep);
+        }
+    }
+
+    @Test
+    public void resetPasswordExpiredCodeAndAuthSessionPerActionMultipleTimeouts() throws IOException, MessagingException, InterruptedException {
+        RealmRepresentation realmRep = testRealm().toRepresentation();
+        Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
+
+        //Make sure that one attribute settings won't affect the other
+        realmRep.setAttributes(UserActionTokenBuilder.create().resetCredentialsLifespan(60).verifyEmailLifespan(300).build());
+        testRealm().update(realmRep);
+
+        try {
+            initiateResetPasswordFromResetPasswordPage("login-test");
+
+            events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
+                    .session((String)null)
+                    .user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
+
+            assertEquals(1, greenMail.getReceivedMessages().length);
+
+            MimeMessage message = greenMail.getReceivedMessages()[0];
+
+            String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message).replace("&amp;", "&");
+
+            setTimeOffset(70);
+
+            log.debug("Going to reset password URI.");
+            driver.navigate().to(oauth.AUTH_SERVER_ROOT + "/realms/test/login-actions/reset-credentials"); // This is necessary to delete KC_RESTART cookie that is restricted to /auth/realms/test path
+            log.debug("Removing cookies.");
+            driver.manage().deleteAllCookies();
+            driver.navigate().to(changePasswordUrl.trim());
+
+            errorPage.assertCurrent();
+            Assert.assertEquals("Action expired.", errorPage.getError());
+            String backToAppLink = errorPage.getBackToApplicationLink();
+            Assert.assertTrue(backToAppLink.endsWith("/app/auth"));
+
+            events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR).error("expired_code").client((String) null).user(userId).session((String) null).clearDetails().detail(Details.ACTION, ResetCredentialsActionToken.TOKEN_TYPE).assertEvent();
+        } finally {
+            setTimeOffset(0);
+
+            realmRep.setAttributes(originalAttributes);
+            testRealm().update(realmRep);
+        }
+    }
 
     // KEYCLOAK-5061
     @Test
@@ -495,7 +660,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
 
             MimeMessage message = greenMail.getReceivedMessages()[0];
 
-            String changePasswordUrl = getPasswordResetEmailLink(message);
+            String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
 
             setTimeOffset(70);
 
@@ -515,6 +680,103 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
     }
 
     @Test
+    public void resetPasswordExpiredCodeForgotPasswordFlowPerActionLifespan() throws IOException, MessagingException, InterruptedException {
+        RealmRepresentation realmRep = testRealm().toRepresentation();
+        Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
+
+        realmRep.setAttributes(UserActionTokenBuilder.create().resetCredentialsLifespan(60).build());
+        testRealm().update(realmRep);
+
+        try {
+            // Redirect directly to KC "forgot password" endpoint instead of "authenticate" endpoint
+            String loginUrl = oauth.getLoginFormUrl();
+            String forgotPasswordUrl = loginUrl.replace("/auth?", "/forgot-credentials?"); // Workaround, but works
+
+            driver.navigate().to(forgotPasswordUrl);
+            resetPasswordPage.assertCurrent();
+            resetPasswordPage.changePassword("login-test");
+
+            loginPage.assertCurrent();
+            assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage());
+            expectedMessagesCount++;
+
+            events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
+                    .session((String)null)
+                    .user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
+
+            assertEquals(1, greenMail.getReceivedMessages().length);
+
+            MimeMessage message = greenMail.getReceivedMessages()[0];
+
+            String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
+
+            setTimeOffset(70);
+
+            driver.navigate().to(changePasswordUrl.trim());
+
+            resetPasswordPage.assertCurrent();
+
+            assertEquals("Action expired. Please start again.", loginPage.getError());
+
+            events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR).error("expired_code").client((String) null).user(userId).session((String) null).clearDetails().detail(Details.ACTION, ResetCredentialsActionToken.TOKEN_TYPE).assertEvent();
+        } finally {
+            setTimeOffset(0);
+
+            realmRep.setAttributes(originalAttributes);
+            testRealm().update(realmRep);
+        }
+    }
+
+    @Test
+    public void resetPasswordExpiredCodeForgotPasswordFlowPerActionMultipleTimeouts() throws IOException, MessagingException, InterruptedException {
+        RealmRepresentation realmRep = testRealm().toRepresentation();
+        Map<String, String> originalAttributes = Collections.unmodifiableMap(new HashMap<>(realmRep.getAttributes()));
+
+        //Make sure that one attribute settings won't affect the other
+        realmRep.setAttributes(UserActionTokenBuilder.create().resetCredentialsLifespan(60).verifyEmailLifespan(300).build());
+        testRealm().update(realmRep);
+
+        try {
+            // Redirect directly to KC "forgot password" endpoint instead of "authenticate" endpoint
+            String loginUrl = oauth.getLoginFormUrl();
+            String forgotPasswordUrl = loginUrl.replace("/auth?", "/forgot-credentials?"); // Workaround, but works
+
+            driver.navigate().to(forgotPasswordUrl);
+            resetPasswordPage.assertCurrent();
+            resetPasswordPage.changePassword("login-test");
+
+            loginPage.assertCurrent();
+            assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage());
+            expectedMessagesCount++;
+
+            events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
+                    .session((String)null)
+                    .user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
+
+            assertEquals(1, greenMail.getReceivedMessages().length);
+
+            MimeMessage message = greenMail.getReceivedMessages()[0];
+
+            String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
+
+            setTimeOffset(70);
+
+            driver.navigate().to(changePasswordUrl.trim());
+
+            resetPasswordPage.assertCurrent();
+
+            assertEquals("Action expired. Please start again.", loginPage.getError());
+
+            events.expectRequiredAction(EventType.EXECUTE_ACTION_TOKEN_ERROR).error("expired_code").client((String) null).user(userId).session((String) null).clearDetails().detail(Details.ACTION, ResetCredentialsActionToken.TOKEN_TYPE).assertEvent();
+        } finally {
+            setTimeOffset(0);
+
+            realmRep.setAttributes(originalAttributes);
+            testRealm().update(realmRep);
+        }
+    }
+
+    @Test
     public void resetPasswordDisabledUser() throws IOException, MessagingException, InterruptedException {
         UserRepresentation user = findUser("login-test");
         try {
@@ -608,7 +870,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
 
         MimeMessage message = greenMail.getReceivedMessages()[0];
 
-        String changePasswordUrl = getPasswordResetEmailLink(message);
+        String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
 
         events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).session((String)null).user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
 
@@ -701,7 +963,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
 
         MimeMessage message = greenMail.getReceivedMessages()[0];
 
-        String changePasswordUrl = getPasswordResetEmailLink(message);
+        String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
 
         log.debug("Going to reset password URI.");
         driver.navigate().to(resetUri); // This is necessary to delete KC_RESTART cookie that is restricted to /auth/realms/test path
@@ -710,8 +972,6 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
         log.debug("Going to URI from e-mail.");
         driver.navigate().to(changePasswordUrl.trim());
 
-//        System.out.println(driver.getPageSource());
-
         updatePasswordPage.assertCurrent();
 
         updatePasswordPage.changePassword("resetPassword", "resetPassword");
@@ -719,32 +979,4 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
         infoPage.assertCurrent();
         assertEquals("Your account has been updated.", infoPage.getInfo());
     }
-
-    public static String getPasswordResetEmailLink(MimeMessage message) throws IOException, MessagingException {
-    	Multipart multipart = (Multipart) message.getContent();
-
-        final String textContentType = multipart.getBodyPart(0).getContentType();
-
-        assertEquals("text/plain; charset=UTF-8", textContentType);
-
-        final String textBody = (String) multipart.getBodyPart(0).getContent();
-        final String textChangePwdUrl = MailUtils.getLink(textBody);
-
-        final String htmlContentType = multipart.getBodyPart(1).getContentType();
-
-        assertEquals("text/html; charset=UTF-8", htmlContentType);
-
-        final String htmlBody = (String) multipart.getBodyPart(1).getContent();
-        
-        // .replace() accounts for escaping the ampersand
-        // It's not escaped in the html version because html retrieved from a
-        // message bundle is considered safe and it must be unescaped to display
-        // properly.
-        final String htmlChangePwdUrl = MailUtils.getLink(htmlBody).replace("&", "&amp;");
-
-        assertEquals(htmlChangePwdUrl, textChangePwdUrl);
-
-        return htmlChangePwdUrl;
-    }
-
 }
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AssertAdminEvents.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AssertAdminEvents.java
index ec1156a..fc82162 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AssertAdminEvents.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AssertAdminEvents.java
@@ -39,7 +39,6 @@ import org.keycloak.testsuite.AbstractKeycloakTest;
 import org.keycloak.testsuite.Assert;
 import org.keycloak.util.JsonSerialization;
 
-import javax.ws.rs.core.Response;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.lang.reflect.Method;
@@ -251,7 +250,7 @@ public class AssertAdminEvents implements TestRule {
 
                             // Reflection-based comparing for other types - compare the non-null fields of "expected" representation with the "actual" representation from the event
                             for (Method method : Reflections.getAllDeclaredMethods(expectedRep.getClass())) {
-                                if (method.getName().startsWith("get") || method.getName().startsWith("is")) {
+                                if (method.getParameterCount() == 0 && (method.getName().startsWith("get") || method.getName().startsWith("is"))) {
                                     Object expectedValue = Reflections.invokeMethod(method, expectedRep);
                                     if (expectedValue != null) {
                                         Object actualValue = Reflections.invokeMethod(method, actualRep);
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/UserActionTokenBuilder.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/UserActionTokenBuilder.java
new file mode 100644
index 0000000..43685cf
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/UserActionTokenBuilder.java
@@ -0,0 +1,38 @@
+package org.keycloak.testsuite.util;
+
+import org.keycloak.authentication.actiontoken.resetcred.ResetCredentialsActionToken;
+import org.keycloak.authentication.actiontoken.verifyemail.VerifyEmailActionToken;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * @author <a href="mailto:bruno@abstractj.org">Bruno Oliveira</a>
+ */
+public class UserActionTokenBuilder {
+
+    private final Map<String, String> realmAttributes;
+    private static final String ATTR_PREFIX = "actionTokenGeneratedByUserLifespan.";
+
+    private UserActionTokenBuilder(HashMap<String, String> attr) {
+        realmAttributes = attr;
+    }
+
+    public static UserActionTokenBuilder create() {
+        return new UserActionTokenBuilder(new HashMap<>());
+    }
+
+    public UserActionTokenBuilder resetCredentialsLifespan(int lifespan) {
+        realmAttributes.put(ATTR_PREFIX + ResetCredentialsActionToken.TOKEN_TYPE, String.valueOf(lifespan));
+        return this;
+    }
+
+    public UserActionTokenBuilder verifyEmailLifespan(int lifespan) {
+        realmAttributes.put(ATTR_PREFIX + VerifyEmailActionToken.TOKEN_TYPE, String.valueOf(lifespan));
+        return this;
+    }
+
+    public Map<String, String> build() {
+        return realmAttributes;
+    }
+}
diff --git a/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/realm/TokenSettings.java b/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/realm/TokenSettings.java
index e93c34f..ff131be 100644
--- a/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/realm/TokenSettings.java
+++ b/testsuite/integration-arquillian/tests/other/console/src/main/java/org/keycloak/testsuite/console/page/realm/TokenSettings.java
@@ -61,6 +61,18 @@ public class TokenSettings extends RealmSettings {
         @FindBy(name = "ssoSessionMaxLifespanUnit")
         private Select sessionLifespanTimeoutUnit;
 
+        @FindBy(name = "actionTokenAttributeSelect")
+        private Select actionTokenAttributeSelect;
+
+        @FindBy(name = "actionTokenAttributeUnit")
+        private Select actionTokenAttributeUnit;
+
+        @FindBy(id = "actionTokenAttributeTime")
+        private WebElement actionTokenAttributeTime;
+
+        @FindBy(xpath = "//button[@data-ng-click='resetToDefaultToken(actionTokenId)']")
+        private WebElement resetButton;
+
         public void setSessionTimeout(int timeout, TimeUnit unit) {
             setTimeout(sessionTimeoutUnit, sessionTimeout, timeout, unit);
         }
@@ -69,6 +81,12 @@ public class TokenSettings extends RealmSettings {
             setTimeout(sessionLifespanTimeoutUnit, sessionLifespanTimeout, time, unit);
         }
 
+        public void setOperation(String tokenType, int time, TimeUnit unit) {
+            waitUntilElement(sessionTimeout).is().present();
+            actionTokenAttributeSelect.selectByValue(tokenType.toLowerCase());
+            setTimeout(actionTokenAttributeUnit, actionTokenAttributeTime, time, unit);
+        }
+
         private void setTimeout(Select timeoutElement, WebElement unitElement,
                 int timeout, TimeUnit unit) {
             waitUntilElement(sessionTimeout).is().present();
@@ -77,5 +95,25 @@ public class TokenSettings extends RealmSettings {
             unitElement.sendKeys(valueOf(timeout));
         }
 
+        public boolean isOperationEquals(String tokenType, int timeout, TimeUnit unit) {
+            selectOperation(tokenType);
+
+            waitUntilElement(sessionTimeout).is().present();
+            actionTokenAttributeSelect.selectByValue(tokenType.toLowerCase());
+
+            return actionTokenAttributeTime.getAttribute("value").equals(Integer.toString(timeout)) &&
+                    actionTokenAttributeUnit.getFirstSelectedOption().getText().equals(capitalize(unit.name().toLowerCase()));
+        }
+
+        public void resetActionToken(String tokenType) {
+            selectOperation(tokenType);
+            waitUntilElement(resetButton).is().visible();
+            resetButton.click();
+        }
+
+        public void selectOperation(String tokenType) {
+            waitUntilElement(sessionTimeout).is().present();
+            actionTokenAttributeSelect.selectByValue(tokenType.toLowerCase());
+        }
     }
 }
diff --git a/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/AbstractConsoleTest.java b/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/AbstractConsoleTest.java
index 0b02193..d01e4a5 100644
--- a/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/AbstractConsoleTest.java
+++ b/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/AbstractConsoleTest.java
@@ -71,8 +71,6 @@ public abstract class AbstractConsoleTest extends AbstractAuthTest {
         if (!testContext.isAdminLoggedIn()) {
             loginToMasterRealmAdminConsoleAs(adminUser);
             testContext.setAdminLoggedIn(true);
-        } else {
-//            adminConsoleRealmPage.navigateTo();
         }
     }
 
diff --git a/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/realm/TokensTest.java b/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/realm/TokensTest.java
index b022d13..cd7d309 100644
--- a/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/realm/TokensTest.java
+++ b/testsuite/integration-arquillian/tests/other/console/src/test/java/org/keycloak/testsuite/console/realm/TokensTest.java
@@ -17,13 +17,27 @@
  */
 package org.keycloak.testsuite.console.realm;
 
+import org.hamcrest.Matchers;
 import org.jboss.arquillian.graphene.page.Page;
 import org.junit.Before;
 import org.junit.Test;
+import org.keycloak.authentication.actiontoken.resetcred.ResetCredentialsActionToken;
+import org.keycloak.authentication.actiontoken.verifyemail.VerifyEmailActionToken;
+import org.keycloak.models.jpa.entities.RealmAttributes;
+import org.keycloak.testsuite.auth.page.account.Account;
 import org.keycloak.testsuite.console.page.realm.TokenSettings;
+import org.keycloak.testsuite.console.page.users.UserAttributes;
+import org.keycloak.testsuite.pages.VerifyEmailPage;
 
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
 import java.util.concurrent.TimeUnit;
 
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
 import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWith;
 import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWithLoginUrlOf;
 
@@ -36,13 +50,20 @@ public class TokensTest extends AbstractRealmTest {
     @Page
     private TokenSettings tokenSettingsPage;
 
+    @Page
+    private UserAttributes userAttributesPage;
+
+    @Page
+    protected VerifyEmailPage verifyEmailPage;
+
+    @Page
+    private Account testRealmAccountPage;
+
     private static final int TIMEOUT = 1;
     private static final TimeUnit TIME_UNIT = TimeUnit.MINUTES;
 
     @Before
     public void beforeTokensTest() {
-//        configure().realmSettings();
-//        tabs().tokens();
         tokenSettingsPage.navigateTo();
     }
 
@@ -78,10 +99,92 @@ public class TokensTest extends AbstractRealmTest {
         assertCurrentUrlStartsWithLoginUrlOf(testRealmPage); // assert logged out (lifespan exceeded)
     }
 
+    @Test
+    public void testLifespanOfVerifyEmailActionTokenPropagated() throws InterruptedException {
+        tokenSettingsPage.form().setOperation(VerifyEmailActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.DAYS);
+        tokenSettingsPage.form().save();
+        assertAlertSuccess();
+
+        loginToTestRealmConsoleAs(testUser);
+        driver.navigate().refresh();
+
+        tokenSettingsPage.navigateTo();
+        tokenSettingsPage.form().selectOperation(VerifyEmailActionToken.TOKEN_TYPE);
+
+        assertTrue("User action token for verify e-mail expected",
+                tokenSettingsPage.form().isOperationEquals(VerifyEmailActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.DAYS));
+
+    }
+
+    @Test
+    public void testLifespanActionTokenPropagatedForVerifyEmailAndResetPassword() throws InterruptedException {
+        tokenSettingsPage.form().setOperation(VerifyEmailActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.DAYS);
+        tokenSettingsPage.form().setOperation(ResetCredentialsActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.HOURS);
+        tokenSettingsPage.form().save();
+        assertAlertSuccess();
+
+        loginToTestRealmConsoleAs(testUser);
+        driver.navigate().refresh();
+
+        tokenSettingsPage.navigateTo();
+        assertTrue("User action token for verify e-mail expected",
+                tokenSettingsPage.form().isOperationEquals(VerifyEmailActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.DAYS));
+
+        assertTrue("User action token for reset credentials expected",
+                tokenSettingsPage.form().isOperationEquals(ResetCredentialsActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.HOURS));
+
+        //Verify if values were properly propagated
+        Map<String, Integer> userActionTokens = getUserActionTokens();
+
+        assertThat("Action Token attributes list should contain 2 items", userActionTokens.entrySet(), Matchers.hasSize(2));
+        assertThat(userActionTokens, Matchers.hasEntry(VerifyEmailActionToken.TOKEN_TYPE, Long.toString(TimeUnit.DAYS.toSeconds(TIMEOUT))));
+        assertThat(userActionTokens, Matchers.hasEntry(ResetCredentialsActionToken.TOKEN_TYPE, Long.toString(TimeUnit.HOURS.toSeconds(TIMEOUT))));
+
+    }
+
+    @Test
+    public void testLifespanActionTokenResetForVerifyEmail() throws InterruptedException {
+        tokenSettingsPage.form().setOperation(VerifyEmailActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.DAYS);
+        tokenSettingsPage.form().setOperation(ResetCredentialsActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.HOURS);
+        tokenSettingsPage.form().save();
+        assertAlertSuccess();
+
+        loginToTestRealmConsoleAs(testUser);
+        driver.navigate().refresh();
+
+        tokenSettingsPage.navigateTo();
+        assertTrue("User action token for verify e-mail expected",
+                tokenSettingsPage.form().isOperationEquals(VerifyEmailActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.DAYS));
+
+        assertTrue("User action token for reset credentials expected",
+                tokenSettingsPage.form().isOperationEquals(ResetCredentialsActionToken.TOKEN_TYPE, TIMEOUT, TimeUnit.HOURS));
+
+        //Remove VerifyEmailActionToken and reset attribute
+        tokenSettingsPage.form().resetActionToken(VerifyEmailActionToken.TOKEN_TYPE);
+        tokenSettingsPage.form().save();
+
+        //Verify if values were properly propagated
+        Map<String, Integer> userActionTokens = getUserActionTokens();
+
+        assertTrue("Action Token attributes list should contain 1 item", userActionTokens.size() == 1);
+        assertNull("VerifyEmailActionToken should not exist", userActionTokens.get(VerifyEmailActionToken.TOKEN_TYPE));
+        assertEquals("ResetCredentialsActionToken expected to be propagated",
+                userActionTokens.get(ResetCredentialsActionToken.TOKEN_TYPE).longValue(), TimeUnit.HOURS.toSeconds(TIMEOUT));
+
+    }
+
+    private Map<String, Integer> getUserActionTokens() {
+        Map<String, Integer> userActionTokens = new HashMap<>();
+        adminClient.realm(testRealmPage.getAuthRealm()).toRepresentation().getAttributes().entrySet().stream()
+                .filter(Objects::nonNull)
+                .filter(entry -> entry.getKey().startsWith(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN + "."))
+                .forEach(entry -> userActionTokens.put(entry.getKey().substring(RealmAttributes.ACTION_TOKEN_GENERATED_BY_USER_LIFESPAN.length() + 1), Integer.valueOf(entry.getValue())));
+        return userActionTokens;
+    }
+
     private void waitForTimeout (int timeout) throws InterruptedException {
         log.info("Wait for timeout: " + timeout + " " + TIME_UNIT);
         TIME_UNIT.sleep(timeout);
         log.info("Timeout reached");
     }
-
 }
diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
index 4a6798d..66b1943 100644
--- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
+++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties
@@ -114,6 +114,15 @@ action-token-generated-by-admin-lifespan=Default Admin-Initiated Action Lifespan
 action-token-generated-by-admin-lifespan.tooltip=Maximum time before an action permit sent to a user by admin 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-Initiated Action Lifespan
 action-token-generated-by-user-lifespan.tooltip=Maximum time before an action permit sent by a user (e.g. forgot password e-mail) is expired. This value is recommended to be short because it is expected that the user would react to self-created action quickly.
+
+action-token-generated-by-user.execute-actions=Execute Actions
+action-token-generated-by-user.idp-verify-account-via-email=IdP Account E-mail Verification
+action-token-generated-by-user.reset-credentials=Forgot Password
+action-token-generated-by-user.verify-email=E-mail Verification
+action-token-generated-by-user.tooltip=Override default settings of maximum time before an action permit sent by a user (e.g. forgot password e-mail) is expired for specific action. This value is recommended to be short because it is expected that the user would react to self-created action quickly.
+action-token-generated-by-user.reset=Reset
+action-token-generated-by-user.operation=Override User-Initiated Action Lifespan
+
 client-login-timeout=Client login timeout
 client-login-timeout.tooltip=Max time an client has to finish the access token protocol. This should normally be 1 minute.
 login-timeout=Login timeout
diff --git a/themes/src/main/resources/theme/base/admin/resources/js/app.js b/themes/src/main/resources/theme/base/admin/resources/js/app.js
index 936b26b..540f236 100755
--- a/themes/src/main/resources/theme/base/admin/resources/js/app.js
+++ b/themes/src/main/resources/theme/base/admin/resources/js/app.js
@@ -195,6 +195,9 @@ module.config([ '$routeProvider', function($routeProvider) {
         .when('/realms/:realm/token-settings', {
             templateUrl : resourceUrl + '/partials/realm-tokens.html',
             resolve : {
+                serverInfo : function(ServerInfoLoader) {
+                    return ServerInfoLoader();
+                },
                 realm : function(RealmLoader) {
                     return RealmLoader();
                 }
diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js
index ca16f91..db3fd4b 100644
--- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js
+++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js
@@ -1065,8 +1065,10 @@ module.controller('RealmIdentityProviderExportCtrl', function(realm, identityPro
     }
 });
 
-module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $location, $route, Dialog, Notifications, TimeUnit, TimeUnit2) {
+module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $location, $route, Dialog, Notifications, TimeUnit, TimeUnit2, serverInfo) {
     $scope.realm = realm;
+    $scope.serverInfo = serverInfo;
+    $scope.actionTokenProviders = $scope.serverInfo.providers.actionTokenHandler.providers;
 
     $scope.realm.accessTokenLifespan = TimeUnit2.asUnit(realm.accessTokenLifespan);
     $scope.realm.accessTokenLifespanForImplicitFlow = TimeUnit2.asUnit(realm.accessTokenLifespanForImplicitFlow);
@@ -1078,6 +1080,7 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, 
     $scope.realm.accessCodeLifespanUserAction = TimeUnit2.asUnit(realm.accessCodeLifespanUserAction);
     $scope.realm.actionTokenGeneratedByAdminLifespan = TimeUnit2.asUnit(realm.actionTokenGeneratedByAdminLifespan);
     $scope.realm.actionTokenGeneratedByUserLifespan = TimeUnit2.asUnit(realm.actionTokenGeneratedByUserLifespan);
+    $scope.realm.attributes = realm.attributes
 
     var oldCopy = angular.copy($scope.realm);
     $scope.changed = false;
@@ -1088,6 +1091,17 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, 
         }
     }, true);
 
+    $scope.$watch('actionLifespanId', function () {
+        $scope.actionTokenAttribute = TimeUnit2.asUnit($scope.realm.attributes['actionTokenGeneratedByUserLifespan.' + $scope.actionLifespanId]);
+    }, true);
+
+    $scope.$watch('actionTokenAttribute', function () {
+        if ($scope.actionLifespanId != null && $scope.actionTokenAttribute != null) {
+            $scope.changed = true;
+            $scope.realm.attributes['actionTokenGeneratedByUserLifespan.' + $scope.actionLifespanId] = $scope.actionTokenAttribute.toSeconds();
+        }
+    }, true);
+
     $scope.changeRevokeRefreshToken = function() {
 
     };
@@ -1109,6 +1123,13 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, 
             Notifications.success("The changes have been saved to the realm.");
         });
     };
+    
+    $scope.resetToDefaultToken = function (actionTokenId) {
+        $scope.actionTokenAttribute = {};
+        delete $scope.realm.attributes['actionTokenGeneratedByUserLifespan.' + $scope.actionLifespanId];
+        //Only for UI effects, resets to the original state
+        $scope.actionTokenAttribute.unit = 'Minutes';
+    }
 
     $scope.reset = function() {
         $route.reload();
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html b/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html
index 81374d6..3b05fdb 100755
--- a/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html
+++ b/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html
@@ -188,6 +188,32 @@
         </div>
 
         <div class="form-group">
+            <label class="col-md-2 control-label" for="actionTokenAttributeSelect" class="two-lines">
+                {{:: 'action-token-generated-by-user.operation' | translate }} </label>
+            <div class="form-inline col-md-6 time-selector">
+                <select class="form-control" name="actionTokenAttributeSelect" id="actionTokenAttributeSelect"
+                        ng-model="actionLifespanId">
+                    <option value="" disabled selected>{{:: 'select-one.placeholder' | translate}}</option>
+                    <option ng-repeat="(actionTokenId, value) in actionTokenProviders" value="{{actionTokenId}}">
+                        {{:: 'action-token-generated-by-user.' + actionTokenId | translate }}
+                    </option>
+                </select>
+                <input class="form-control" type="number" min="1" max="31536000" data-ng-model="actionTokenAttribute.time"
+                       id="actionTokenAttributeTime" name="actionTokenAttributeTime">
+                <select class="form-control" name="actionTokenAttributeUnit"
+                        data-ng-model="actionTokenAttribute.unit">
+                    <option value="Minutes" ng-selected="true">{{:: 'minutes' | translate}}</option>
+                    <option value="Hours">{{:: 'hours' | translate}}</option>
+                    <option value="Days">{{:: 'days' | translate}}</option>
+                </select>
+                <button data-ng-click="resetToDefaultToken(actionTokenId)">{{:: 'action-token-generated-by-user.reset' | translate}}</button>
+            </div>
+            <kc-tooltip>
+                {{:: 'action-token-generated-by-user.tooltip' | translate}}
+            </kc-tooltip>
+        </div>
+
+        <div class="form-group">
             <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-reset data-ng-disabled="!changed">{{:: 'cancel' | translate}}</button>