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 f3ae325a73..bfa00e0b43 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 @@ -454,6 +454,12 @@ public class RealmAdapter implements CachedRealmModel { updated.setAccessCodeLifespanUserAction(seconds); } + @Override + public Map getUserActionTokenLifespans() { + if (isUpdated()) return updated.getUserActionTokenLifespans(); + return cached.getUserActionTokenLifespans(); + } + @Override public int getAccessCodeLifespanLogin() { if (isUpdated()) return updated.getAccessCodeLifespanLogin(); @@ -490,6 +496,20 @@ public class RealmAdapter implements CachedRealmModel { updated.setActionTokenGeneratedByUserLifespan(seconds); } + @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 getRequiredCredentials() { if (isUpdated()) return updated.getRequiredCredentials(); 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 52a81defc8..e8540021d7 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 attributes; + private Map 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 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 getRequiredCredentials() { return requiredCredentials; } @@ -609,5 +629,4 @@ public class CachedRealm extends AbstractExtendableRevisioned { public Map getAttributes() { return attributes; } - } 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 862db6c123..d82144dcbf 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 @@ -473,6 +473,19 @@ public class RealmAdapter implements RealmModel, JpaModel { em.flush(); } + @Override + public Map getUserActionTokenLifespans() { + + Map 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 { 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 { entities.remove(entity); } em.flush(); - } + } @Override public List getDefaultGroups() { @@ -1802,7 +1826,7 @@ public class RealmAdapter implements RealmModel, JpaModel { /** * 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 { 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 4d7090ec52..6d48425d7a 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 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 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 d118634417..093fbb70c1 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 4ac9bffdaa..a61d4db3d7 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 baa3c4e9b4..4208308e8f 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 69aa142ee0..add2a89125 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("&", "&"); 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 f4c745268d..55dd453979 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 Stian Thorgersen @@ -448,6 +449,95 @@ public class RequiredActionEmailVerificationTest extends AbstractTestRealmKeyclo } } + @Test + public void verifyEmailExpiredCodedPerActionLifespan() throws IOException, MessagingException { + RealmRepresentation realmRep = testRealm().toRepresentation(); + Map 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 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(); 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 da54a72301..15477fea19 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 bc4379eb7f..a009820d2c 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 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 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("&", "&"); + String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message).replace("&", "&"); setTimeOffset(70); @@ -463,6 +542,92 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { } } + @Test + public void resetPasswordExpiredCodeAndAuthSessionPerActionLifespan() throws IOException, MessagingException, InterruptedException { + RealmRepresentation realmRep = testRealm().toRepresentation(); + Map 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("&", "&"); + + 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 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("&", "&"); + + 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); @@ -514,6 +679,103 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest { } } + @Test + public void resetPasswordExpiredCodeForgotPasswordFlowPerActionLifespan() throws IOException, MessagingException, InterruptedException { + RealmRepresentation realmRep = testRealm().toRepresentation(); + Map 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 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"); @@ -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("&", "&"); - - 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 ec1156a42c..fc82162480 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 0000000000..43685cf257 --- /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 Bruno Oliveira + */ +public class UserActionTokenBuilder { + + private final Map realmAttributes; + private static final String ATTR_PREFIX = "actionTokenGeneratedByUserLifespan."; + + private UserActionTokenBuilder(HashMap 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 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 e93c34fc2e..ff131becbf 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 0b02193836..d01e4a5660 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 b022d1310d..cd7d309873 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 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 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 getUserActionTokens() { + Map 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 4a6798d969..66b194305e 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 936b26bc08..540f236b68 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 ca16f91a4b..db3fd4bb18 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 81374d6eff..3b05fdba1c 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 @@ -187,6 +187,32 @@ +
+ +
+ + + + +
+ + {{:: 'action-token-generated-by-user.tooltip' | translate}} + +
+